Skip to content
Docent Docs

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

bash
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:

bash
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:

bash
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:

bash
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:

php
// 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:

php
'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:

php
// 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:

php
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:

php
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. Pass null for a guest.

  • ->forAudience($name): evaluate as a named audience.

  • ->assertVisible() / ->assertNotVisible(): the page's authorize and audience gates.

  • ->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:

php
$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:

php
$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:

php
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:

yaml
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.