Writing Docs
Validation
Validate your docs like code: docent:check in CI and visibility assertions in your test suite.
Documentation that lives in your repository can be validated in your repository. Docent ships a static checker for your CI pipeline and test helpers for your suite, so a broken link or a leaked gated page fails the build instead of shipping.
docent:check
php artisan docent:check
The checker walks the entire documentation tree and reports problems grouped by page, each with a file and line number. Among the checks:
Broken internal links
Unknown values, links, conditions, components, and audiences
Nonexistent named routes
Unknown gate abilities
Registered tokens trapped inside a code span, where they render verbatim
Missing includes and include cycles
Images that don't resolve, climb out of the documentation directory, or name a file type Docent won't serve
Duplicate slugs
Heading hierarchy jumps
Unknown icons
Invalid or unresolved persistent navigation links
Nested or empty top-level sections
Front-matter problems (invalid YAML, missing title)
Pages shadowed by the admin panel
Errors exit non-zero. Warnings don't fail the build by default; add --strict
to treat them as failures too:
php artisan docent:check --strict
On a multi-site install the checker runs every site's corpus and also
validates the site definitions themselves: a site without a content path is an
error, and two sites whose routes would collide is a warning. Scope a run to
one site with --site:
php artisan docent:check --site=admin
Reference errors are precise: rename a route and docent:check names the page,
the line, and the stale identifier. That's the exact drift a static docs
platform would happily publish.
Machine-readable output
Add --format=json for a structured report a script or coding agent can parse:
php artisan docent:check --format=json
It emits a single JSON object — ok, pages, errors, warnings, and an
issues array whose entries each carry a check (a stable rule id like
broken-link), severity, slug, line, and message. This is what powers
the write-then-check loop for agents that write docs: the agent
runs the checker, reads the findings, and fixes its own work before you review.
Tuning rules
Every finding has a stable rule id. Override a rule's severity — or silence it —
in the shared check config:
// config/docent.php
'check' => [
'rules' => [
'heading-hierarchy' => 'warning',
'missing-image' => 'off',
],
],
Map any rule to error, warning (or warn), or off.
Some rules are authoring-quality checks that stay off until you ask for them — they raise the bar on style, not just correctness. Name one with a severity to enable it:
'check' => [
'rules' => [
'single-h1' => 'warning', // a body h1 duplicates the page title
'description-length' => 'warning', // a description over ~160 characters
'gated-link' => 'warning', // a link its readers may not be able to follow
],
],
Because they're opt-in, a fresh install stays quiet until your team decides to enforce them.
Rules apply wherever checks run, so a rule you silence here is silent in the admin editor's per-draft validation too rather than nagging on every save.
gated-link is worth a closer look, because it catches something the
broken-link check structurally cannot. See
access control.
Declaring your abilities
To validate authorize: front matter and :::can blocks, the checker has to
know which abilities exist. By default it asks Gate::has(), which only sees
abilities passed to Gate::define().
That's a problem if your app bridges permissions through a single Gate::before
callback, which is the natural shape when the permission list is data rather
than a set of hand-written closures. Such an app defines no gates at all, so
every authorize: key in your content reads as a typo. Declare the surface
instead:
// config/docent.php
'check' => [
'abilities' => App\Enums\Permission::class,
],
A backed enum is the common case, and a plain list of strings works too. When
the list is dynamic, register a closure from a service provider rather than
putting one in config, since a closure in a config file breaks config:cache:
Docent::abilities(fn () => Permission::query()->pluck('name')->all());
A declared surface replaces Gate::has() rather than adding to it, so name
every ability your docs may reference. The admin editor offers the same list
when completing an authorize: key.
Testing visibility
The whole point of permission-aware docs is that different viewers see different
things. Assert that directly in your test suite with the InteractsWithDocs
trait:
use STS\Docent\Testing\InteractsWithDocs;
class DocsVisibilityTest extends TestCase
{
use InteractsWithDocs;
public function test_admins_and_members_see_different_docs(): void
{
$this->docs()->page('billing/payment-methods')->as($admin)
->assertVisible()
->assertSee('Add a card');
$this->docs()->page('reports')->as($member)
->assertNotVisible();
}
}
The page tester drives the real render pipeline for a given viewer:
->as($user): set the viewer. Passnullfor a guest.->forAudience($name): evaluate as a named audience.->assertVisible()/->assertNotVisible(): the page'sauthorizeandaudiencegates.->assertSee($text)/->assertDontSee($text): assert on the rendered output for that viewer.
Search is authorization-filtered, so you can prove a gated page never leaks into results:
$this->docs()->search('payroll', as: $member)
->assertMissing('Payroll Reports');
$this->docs()->search('installation')
->assertSees('Installation')
->assertCount(1);
The search tester exposes assertSees, assertMissing, assertEmpty, and
assertCount, and takes optional as:, audience:, and limit: arguments.
Sweeping the whole tree
Per-page assertions cover intent. For the question most suites actually want answered, whether the entire site still returns 200 for a given role, sweep it:
$this->docs()->as($member)->assertAllPagesRender();
Pages the viewer can't see are skipped rather than failed, since a sweep for a narrow role asks what that role can reach. A sweep that reached nothing at all fails, though: a gate misconfigured to deny everything would otherwise report green while proving nothing. Failures don't stop the run either, so a broken corpus reports every bad page at once with its slug and error.
For invariants of your own, pages() hands you the real slug list, using
Docent's own derivation rather than a reconstruction of it:
foreach ($this->docs()->pages() as $slug) {
// every page is in navigation, has a description, whatever you need
}
The list covers content pages for the current site. Hidden and locked pages are included, because both are ordinary pages a reader can open directly. Redirect stubs aren't, because a stub is an alias that never renders.
A viewer set with as() or forAudience() applies to everything the tester
hands out, including page() and search(), so you state it once.
In CI
Run the checker as a build step. A minimal GitHub Actions job:
name: docs
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- run: composer install --no-interaction --prefer-dist
- run: php artisan docent:check --strict
Because the docs live beside your code, this runs on the same pull request as the feature it documents. The two ship together or not at all.