intragile / laravel-manual
Headless manual-as-code for Laravel: a Markdown user manual in your repo, rendered and searched server-side, served over a JSON API.
Requires
- php: ^8.3
- illuminate/auth: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/validation: ^12.0|^13.0
- league/commonmark: ^2.6
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.20
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^12.0
Suggests
None
Provides
None
Conflicts
- illuminate/support: <12.0
Replaces
None
README
Developed and maintained by IntrAgile Kft.
A Markdown user manual that lives in your repository, rendered and searched on the server and served over a JSON API with configurable access. A console command checks the manual in CI.
Magyar nyelvű leírás → README.hu.md
- Headless. The package returns data; your SPA or Blade view draws the help page.
- Manual as code. Chapters are Markdown files in your repo, reviewed in the same pull request as the feature. No database, no CMS.
- Translations without a second system. One directory per language. A chapter missing from a translation is served in the next language of the fallback chain and says so.
- Server-side search. Per-language stemming, weighted ranking, plain-text snippets. The full content never leaves the server.
- In-manual markup. Links to other chapters, to routes of your app and to external URLs by key,
icons, and callout containers (
tip,warning, ...), all resolved on the server. - Safe HTML. Raw HTML is stripped and unsafe links are dropped, so
htmlis meant forv-html. - Access you choose. Public, signed-in users only, a gate, or off.
- A broken manual does not ship.
manual:checkreports a missing translation, a diverging structure, a broken link or raw HTML in CI.
Installation
composer require intragile/laravel-manual
Requires PHP 8.3+ and Laravel 12 or 13. The service provider is discovered automatically. Publish the config only if you need to change it:
php artisan vendor:publish --tag=manual-config
Quick start
Read the access section first. With the shipped defaults (access is authenticated, and the
automatic routes sit in the api group only) a request passes only if your default guard
already recognises the user. For a typical token API (Sanctum) it does not, so the endpoints
answer 401 even to signed-in users.
Recommended: turn the automatic routes off and register the routes inside the route group that already authenticates your users. Publish the config first:
php artisan vendor:publish --tag=manual-config
Then, in the published file, switch the automatic routes off and set the languages. locales lists
every language the API may serve, and source_locale is the language you write in first:
// config/manual.php 'path' => resource_path('manual'), 'locales' => ['hu', 'en'], 'source_locale' => 'hu', 'fallback' => ['en'], 'routes' => [ 'enabled' => false, 'prefix' => 'manual', 'middleware' => ['api'], 'name' => 'manual.', ],
// routes/api.php use IntrAgile\Manual\Facades\Manual; Route::prefix('v1')->middleware('auth:sanctum')->group(function () { Manual::routes(); // GET /api/v1/manual, /manual/chapter, /manual/search });
Manual::routes() adds its own prefix (manual) and route-name prefix (manual.) from the
config, so the group above only supplies the outer prefix and the authentication. It does not
apply routes.middleware; that array belongs to the automatic route group only.
Keep the whole routes array in your published config. Laravel merges package config only at the
top level, so a routes array you write replaces the shipped one; keys you leave out fall back to
the defaults in the table below.
Then write the first chapter (see the next section) and check it:
php artisan manual:check
Writing the manual
resources/manual/
_meta.php the tree: sections, their order and chapters (language independent)
_links.php optional: [[link:key]] targets (see below)
hu/
_sections.php section titles in this language
getting-started/
intro.md
first-steps.md
en/
_sections.php
getting-started/
intro.md
first-steps.md
_meta.php returns the tree. A section id is a key; icon is a free string that is passed to
the client as it is, order sorts the sections, and chapters lists chapter slugs in reading
order. A chapter is addressed as {section}/{chapter}, here getting-started/intro.
// resources/manual/_meta.php return [ 'getting-started' => ['icon' => 'rocket', 'order' => 1, 'chapters' => ['intro', 'first-steps']], 'admin' => ['icon' => 'settings', 'order' => 2, 'chapters' => ['users']], ];
{locale}/_sections.php maps each section id to its title in that language:
// resources/manual/hu/_sections.php return ['getting-started' => 'Első lépések', 'admin' => 'Adminisztráció'];
Numbering comes from the position in _meta.php: the second chapter of the first section is
1.2. Because the numbers do not depend on the language, every chapter needs a file in the
source locale; otherwise numbering would shift between languages. manual:check enforces it.
Section ids and chapter slugs must match [A-Za-z0-9_-]+.
Chapter files
A chapter is {path}/{locale}/{section}/{chapter}.md.
- The first
# Titleis the chapter title. Use the ATX form (# Title): a setext title (Titleover=====) is rejected bymanual:check, and the renderer does not read it. A chapter has exactly one H1. - The first paragraph after the title is the summary, shown in the tree and in search.
- Use
##and###for sections. They get anidand appear inheadings.####and deeper are rejected bymanual:check. - Tables, lists, quotes and fenced code blocks are standard CommonMark (GFM tables included).
- Only fenced code blocks count as code.
manual:checkdoes not recognise a 4-space indented code block: its lines are checked as prose. Always fence code with three backticks or~~~. - Raw HTML is not allowed (the renderer strips it,
manual:checkreports it). PutList<String>and similar text in backticks.
Callouts
:::tip Save often. :::
Allowed types are the callouts config (tip, warning, note and steps by default). Rules:
:::typeand the closing:::go at column 0, each on its own line with a blank line before and after. They cannot sit inside a list or a quote;manual:checkreports that.- Callouts can nest. An unknown type, or an unclosed one, is an error in
manual:check.
They render as <div class="manual-callout" data-type="tip">...</div>.
Tokens
| Token | Result |
|---|---|
[[manual:section/chapter]], optionally [[manual:section/chapter|label]] |
<a class="manual-link" data-slug="section/chapter">, a link to another chapter |
[[route:name]], optionally [[route:name|label]] |
<a class="manual-route" data-route="name">, a link to a screen of your app |
[[link:key]], optionally [[link:key|label]] |
<a class="manual-external" href="..." target="_blank" rel="noopener noreferrer"> |
[[icon:name]] |
<i class="manual-icon" data-icon="name" aria-hidden="true"></i> (no label) |
Without a label, the name is shown. The \| in the table is how a pipe is written inside a
Markdown table cell; the same rule applies to your chapters:
- Names are limited to
[A-Za-z0-9._/-]. - A literal
|inside a table cell needs\|, including inside a token.[[manual:a/b\|label]]in a cell is a token with the labellabel. - A token label cannot contain inline markup (
**bold**,`code`, ...); such a token stays literal text. Keep labels plain. - Tokens are resolved only in text, never in code spans or code blocks. To show a token
verbatim, wrap it in a code span:
`[[manual:a/b]]`. Escaping the brackets ([[) does not help outside code: it is still read as a token. manual:checkreports a malformed token, an unknown kind, amanualtarget that is not in_meta.php, an unknownlinkkey and, whenknown_routesis set, an unknown route.
External links come from _links.php (or the links config: an array, or a path to such a
file). The shape is key => locale => URL:
// resources/manual/_links.php return [ 'status' => ['hu' => 'https://example.com/hu/status', 'en' => 'https://example.com/en/status'], ];
The URL is picked for the chapter's locale, then en, then the first one. Only http, https
and mailto URLs are linked; anything else, and an unknown key, renders as the plain label.
Configuration
| Key | Default | Meaning |
|---|---|---|
path |
resource_path('manual') |
The manual directory. |
locales |
['en'] |
The locales the API accepts. List every translation. The source locale is always allowed as well. |
source_locale |
en |
The language every chapter must exist in; the last step of the fallback chain. |
fallback |
['en'] |
Extra languages to try, in order, when the requested one lacks a chapter or is not configured. |
access |
authenticated |
public, authenticated, a gate name or disabled. See below. |
routes.enabled |
true |
Register the routes automatically. Turn off to call Manual::routes() yourself. |
routes.prefix |
manual |
URL prefix. |
routes.middleware |
['api'] |
Middleware of the automatic route group. |
routes.name |
manual. |
Route name prefix (manual.index, manual.chapter, manual.search). |
callouts |
['tip', 'warning', 'note', 'steps'] |
Allowed :::type container names. |
links |
null |
[[link:]] map: an array, a file path, or null for {path}/_links.php if it exists. |
known_routes |
null |
Route names [[route:]] may use, checked by manual:check. null skips the check. A list, or a callable returning an iterable, e.g. 'App\Support\Routes::names'. The callable must be a string callable or a Closure: an array callable is read as a list of names. Prefer a string callable, so config:cache keeps working. |
stemmers |
Hungarian for hu, English for en |
Locale => class implementing Stemmer. See Search. |
search.min_term_length |
3 |
Shorter query terms are ignored. |
search.limit |
10 |
Default number of hits. |
cache.store |
null |
Cache store; null uses the default store. |
cache.prefix |
manual |
Cache key prefix. |
Access
access decides who may call the API. The check runs in a middleware that is part of every
package route, so it applies both to the automatic routes and to Manual::routes().
| Value | Behaviour |
|---|---|
public |
Anyone. |
authenticated |
A user must be signed in; otherwise 401. |
| a gate name | Gate::allows(name) decides. A denied guest gets 401, a denied signed-in user 403. |
disabled |
404 for every route. |
A gate cannot be named public, authenticated or disabled: those words always mean the
behaviour in the table.
authenticated checks $request->user() on the default guard. The package adds no
authentication of its own, and the default route group is only ['api'], so out of the box a
Sanctum token or a session user is not recognised and gets 401. Either:
-
Recommended:
routes.enabled = falseand callManual::routes()inside your own authenticated group, as in the quick start; or -
keep the automatic routes and add your authentication middleware:
'routes' => [ 'enabled' => true, 'prefix' => 'manual', 'middleware' => ['api', 'auth:sanctum'], 'name' => 'manual.', ],
If your Sanctum guard is not the default guard, a gate or a custom group is the safer choice.
Use public only for content you would also put on a public web page.
Middleware of the surrounding group (such as auth:sanctum) runs before the package's own
middleware, including the one that forces Accept: application/json. A guest who sends no
Accept header is therefore rejected by your application's own unauthenticated handling, which by
default redirects to a login route. Make your application render JSON for its API, for example
with $exceptions->shouldRenderJsonWhen(fn ($request) => $request->is('api/*')) in
bootstrap/app.php.
A gate is consulted for guests too, but Laravel only calls a gate callback for a guest if its first
parameter, the user, is nullable or defaults to null. To let guests through a gate, declare it
that way:
use App\Models\User; Gate::define('view-manual', fn (?User $user): bool => $user === null || $user->can_see_help);
A callback that requires a User, or takes no parameter at all, denies every guest with 401.
API
All responses are JSON. The package forces Accept: application/json on its routes and throws
ordinary exceptions (AuthenticationException, ValidationException, NotFoundHttpException),
so your application's exception handler formats the errors, for example as
application/problem+json.
| Endpoint | Description |
|---|---|
GET {prefix} |
The tree: sections with their chapters (slug, number, title, summary). |
GET {prefix}/chapter?slug=section/chapter |
One rendered chapter. 404 if the slug is unknown or has no file in any language of the chain; 422 if slug is missing or longer than 200 characters. |
GET {prefix}/search?q=... |
Search hits. 422 if q is missing or longer than 200 characters. |
Every endpoint also takes ?locale= (a string of up to 20 characters).
{
"data": [
{
"id": "getting-started",
"icon": "rocket",
"title": "Első lépések",
"chapters": [
{ "slug": "getting-started/intro", "number": "1.1", "title": "Bevezetés", "summary": "Mi ez, és kinek szól." }
]
}
],
"locale": "hu",
"fallback": false
}
{
"data": {
"slug": "getting-started/intro",
"number": "1.1",
"title": "Bevezetés",
"summary": "Mi ez, és kinek szól.",
"section": "getting-started",
"position": 1,
"total": 3,
"readingMinutes": 2,
"html": "<p>...</p>",
"headings": [ { "anchor": "elso-lepesek", "text": "Első lépések", "level": 2 } ],
"prev": null,
"next": { "slug": "getting-started/first-steps", "title": "Első lépések" },
"locale": "hu",
"fallback": false
}
}
{
"data": [
{ "slug": "admin/users", "section": "admin", "title": "Felhasználók", "snippet": "... plain-text excerpt ...", "score": 12 }
],
"locale": "hu",
"fallback": false
}
numberis a string ("2.1");prevandnextare{slug, title}, ornullat the first or last chapter; chapters without a file in the chain are skipped.- A chapter that has no file in any language of the chain is left out of the tree, and
number,position,total,prevandnextonly count chapters that exist for the language. - The chapter's
localeis the language of the file actually read, andfallbackistruewhen it differs from the requested language. The chapter response has no top levellocale. - The top level
localeof the tree and of search is the language the request resolved to: the requested language when it is configured, otherwise the first configured language offallback, otherwisesource_locale. Theirfallbackistruewhen the request could not be honoured in its own language. It is language-level: a single chapter that is missing from a configured translation and shown from the fallback does not turn the tree's or the search'sfallbackon. Read the chapter's ownfallbackfor that. htmlis sanitized (see Security) and meant to be inserted withv-html.- Every other text field (
title,summary,headings[].text, section titles, the titles ofprevandnext, and the searchsnippet) is plain text: render it as text, never as HTML.
Language resolution
The language is resolved from the request in this order:
?locale=;- each
Accept-Languagetag, in the client's quality order. A tag withq=0("not acceptable") is ignored. Each tag is tried by its full tag first, then by its primary tag:pt-BRmatches a configuredpt-BR(orpt_BR), otherwisept;hu-HUmatcheshu; - the application locale (
app()->getLocale()), only when the client sent no usable language at all; source_locale.
The first candidate from steps 1 and 2 that is in locales or is the source_locale wins, so
Accept-Language: de, en;q=0.9 serves en when de is not configured. If the client asked for
languages but none of them is configured (say only ?locale=de), the manual is served from the
fallback chain (below) and the tree and search report fallback: true: the client learns that its own
language is not available. A malformed value (?locale=../x) is ignored.
Fallback chain
For a requested language, a chapter is looked up in: the requested language, then the languages in
fallback (only those that are configured), then source_locale. The first file found is served.
A requested language that is not configured (not in locales, not source_locale) is skipped and
never touches the filesystem: the chain is just the configured fallback languages, then
source_locale. With locales ['hu', 'en'], source_locale hu and fallback ['en'], a
request for de is served in en with fallback: true, and from hu for a chapter that has no
en file. With fallback [] it is served from source_locale.
The config fallback is the chain (which languages to try); the fallback flag in a response
only says that the language served is not the language asked for.
Checking the manual
php artisan manual:check
php artisan manual:check --locale=en # the source locale plus this one; repeatable
It checks every configured language (the source locale plus locales) and reports, with the
chapter and language:
_meta.php: missing, not loadable, malformed sections or chapters, an invalid id or slug, duplicate chapters, a manual with no chapters;_sections.php: missing section titles, unknown section keys;- files: a Markdown file that is not listed in
_meta.php(an orphan), a chapter missing in the source locale, a chapter missing in a translation; - structure: no title, more than one H1, a setext title, no summary,
####or deeper headings, unknown, unbalanced, indented or quoted:::markers,:::markers without blank lines; - content: raw HTML, malformed or unresolved tokens, a broken
manuallink, an unknownlinkkey, an unknown route (only withknown_routes), a link URL that would not be linked; - parity: a translation whose block structure (headings, lists, tables, callouts, code blocks, paragraphs) differs from the source locale. A translated code comment or a different line wrap is not a difference; a missing paragraph or section is.
With any error the command prints each message and exits with a non-zero status, so it can gate a pull request:
# .github/workflows/tests.yml - name: Check the manual run: php artisan manual:check
--locale with a language that is not in locales is an error. Fenced code blocks are ignored by
every line rule, and the raw HTML and token checks also ignore inline code spans.
manual:clear invalidates the cache (see below).
Search
The search runs on the server and builds its index lazily. The query (at most 200 characters) is
split into words, accents are folded (ertesites finds Értesítési), and each word is cut to a
stem by the stemmer of the resolved language. A chapter matches when every word occurs in it
as a substring. Hits are ranked by weighted occurrences: title 6, headings 3, summary 2, body 1.
Words shorter than search.min_term_length are ignored.
Built in: HungarianStemmer and EnglishStemmer, light suffix trimmers that keep at least four
characters. A language without a stemmer is searched as typed. Stemming is applied to the
query only; the content is searched as written.
Fallback content is searched with the requested language's stemmer: a Hungarian chapter shown under an English request is searched with the English stemmer.
To register your own, implement IntrAgile\Manual\Search\Stemmer and list it by locale. The class
needs a constructor without arguments, and receives a lowercase ASCII word (accents already folded).
The language must also be listed in locales; the stemmers entry alone does not enable it:
namespace App\Search; use IntrAgile\Manual\Search\Stemmer; final class GermanStemmer implements Stemmer { public function stem(string $normalizedWord): string { if (strlen($normalizedWord) < 5) { return $normalizedWord; } return preg_replace('/(en|er|e|n|s)$/', '', $normalizedWord) ?? $normalizedWord; } }
// config/manual.php 'stemmers' => [ 'hu' => \IntrAgile\Manual\Search\HungarianStemmer::class, 'en' => \IntrAgile\Manual\Search\EnglishStemmer::class, 'de' => \App\Search\GermanStemmer::class, ],
An entry whose class does not exist or does not implement Stemmer is skipped, which means no
stemming for that language.
The snippet of a hit is plain text cut from the chapter around the first match. It is not
sanitized HTML: render it as text ({{ hit.snippet }}), not with v-html, or escape it.
Programmatic use
use IntrAgile\Manual\Facades\Manual; Manual::tree('hu'); // list of sections with chapters Manual::chapter('admin/users', 'hu'); // array or null Manual::markdown('admin/users', 'hu'); // ['markdown' => ..., 'locale' => ...] or null (uncached) Manual::search('számla', 'hu', 5); // list of hits, optional limit
Manual::spy() and Manual::swap() work, because the facade root is the ManualManager service.
The return shapes are the JSON shapes above (without the data wrapper). A slug that is not in
_meta.php gives null. Pass the locale you want; unlike the HTTP layer, the facade does not read
the request.
Frontend tips
- The
htmlof a chapter is safe to insert withv-html. Handle clicks with one delegated listener on the container instead of binding links: look for the closest[data-slug]and open that chapter, or the closest[data-route]and push that route name in your router. The links havetabindex="0"androle="link", so also handle Enter. [[icon:name]]becomes<i class="manual-icon" data-icon="name">. Mapdata-iconto your icon set (CSS, or replace the element); theiconof a section in the tree is the same kind of free string.- Style callouts with
.manual-callout[data-type="tip"](andwarning,note,steps, or whatever you configured). Tables are wrapped in.manual-table-wrapso they can scroll. h2andh3carry anidequal to theanchorinheadings, so you can build a table of contents and scroll to#anchor.- Send
?locale=orAccept-Languagefrom your SPA, which usually keeps the user's language client-side. Readfallbackon a chapter to show a "not translated yet" note. - Render a search
snippetas text, never as HTML.
Public API and semantic versioning
The package follows Semantic Versioning. From 1.0.0 on, a breaking change to any of the following waits for the next major version:
- the
Manualfacade methods:tree(),chapter(),markdown(),search()androutes(); - the
IntrAgile\Manual\Search\Stemmerinterface (so your own stemmers keep working) and the built-in stemmer classesHungarianStemmer,EnglishStemmerandNullStemmer; - the public methods of
ManualManager(tree(),chapter(),markdown()andsearch(), not its constructor); - the config keys and their meaning;
- the routes, their names and query parameters, and the JSON shapes documented above;
- the content format:
_meta.php,_sections.php,_links.php, the token and callout syntax and the rendered markup (manual-link,manual-route,manual-external,manual-icon,manual-callout, and thedata-slug,data-route,data-icon,data-typeattributes); - the
manual:checkandmanual:clearcommands, their options and exit status. The wording of the individual messages is not covered, and new checks may be added in a minor version.
Not covered: every class or method marked @internal (the controller, middleware, resources,
command classes, the locale resolver), and the classes behind the facade: ManualRepository,
ContentTree, ManualSearch, ManualRenderer and ManualValidator. Use the facade.
Caching
The tree, each chapter (rendered HTML included) and the search index are cached in the configured
store. Entries hold only arrays and strings, so they work with stores that refuse to unserialize
objects (cache.serializable_classes = false). A key is built from:
cache.prefix;CACHE_VERSION, a constant of the package, bumped when the stored shape or content changes;- the cache generation;
- the kind of entry (
tree,chapterorsearch) and the effective locale; - a hash of the config that affects output (
locales,source_locale,fallback,callouts, the resolved links, and the modification time and size of an external links file); - a content fingerprint: the path, modification time and size of every
.mdand.phpfile in the manual directory; - the chapter slug, for chapter entries.
A deploy that changes a file or the config therefore shows up without clearing anything. All entries expire after one day, so old keys do not pile up.
An absent content path yields an empty manual, not an error. Run php artisan manual:check against
the deployed build or image too, not only in the repository, so a missing content directory is
noticed.
php artisan manual:clear bumps the generation: every old entry becomes unreachable at once and
simply expires. It works on any cache store (no tags, no key listing). Running workers pick the
new generation up on their next request, because the repository, search and manager bindings are
scoped: a fresh instance per request or job, so Octane and queue workers are safe.
Limitation: an edit made within the same second as the previous one that leaves the file size
exactly the same is not detected. Run manual:clear in that case. In practice it only matters
when you edit a file in place by hand; deploys replace files.
Security
Markdown is rendered with raw HTML stripped (html_input: strip) and unsafe link schemes
such as javascript: removed (allow_unsafe_links: false), so html is safe to insert into a
page. [[link:]] URLs must be http, https or mailto. Locales only ever come from the
configured allow-list before they reach a file name, and a chapter slug must be listed in
_meta.php before a path is built, so a request cannot make the package read an arbitrary file.
See SECURITY.md. The search snippet is plain text: escape it.
Testing and quality
composer test
composer analyse
vendor/bin/pint --test
composer analyse runs PHPStan with --memory-limit=1G. In your own CI call PHPStan with a
--memory-limit as well (vendor/bin/phpstan analyse --memory-limit=1G); at the max level it
can run out of PHP's default memory.
License
MIT. See LICENSE.
Developed and maintained by IntrAgile Kft..