databis / laravel-docs-module
Documentation module for Laravel: nested categories, TipTap-backed articles, images and revisions, exposed as a REST API.
Requires
- php: ^8.1
- illuminate/console: ^10.0|^11.0|^12.0
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/http: ^10.0|^11.0|^12.0
- illuminate/routing: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- intervention/image: ^3.0
- laravel/pint: ^1.13
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.0|^11.0
Suggests
- intervention/image: Required if you enable images.convert_to_webp or images.max_width in the config.
- ueberdosis/tiptap-php: Only required if you render content to HTML server-side (SEO/export).
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-03 14:52:07 UTC
README
A documentation module for Laravel, exposed as a REST API: nested categories, articles whose body is a TipTap/ProseMirror document, image uploads, revision history and full-text search.
It is the backend half of a pair. The frontend half is
@databis/docs-module, an independent, framework-agnostic npm package
that talks to this one over HTTP. Neither depends on the other's code.
- Requires PHP 8.1+ and Laravel 10, 11 or 12.
- Ships no UI. Every endpoint returns JSON.
- Assumes nothing about your roles. You tell it who counts as an admin.
Installation
composer require databis/laravel-docs-module
The service provider is auto-discovered. Publish the config:
php artisan vendor:publish --tag=docs-module-config
Migrations load straight from the package, so this is enough:
php artisan migrate
Then tell the package who may edit documentation — nothing works until you do, because it fails closed:
// app/Providers/AppServiceProvider.php use Databis\DocsModule\Support\DocsAdminResolver; public function boot(): void { DocsAdminResolver::resolveUsing(fn ($user) => $user->hasRole('admin')); }
If your API uses Sanctum tokens, make sure the guard exists:
php artisan install:api
Authorization
There are three ways to answer "who is an admin", tried in this order.
1. A closure (recommended). Registered from a service provider, as above.
Handles anything — Spatie/permission, Jetstream teams, a column on users.
2. A callable in the config.
'admin_resolver' => 'App\Support\Docs@isAdmin', // or ['App\Support\Docs', 'isAdmin']
3. Role ids.
'admin_role_ids' => env('DOCS_ADMIN_ROLE_IDS', '1,2'),
Matched against $user->roles (plucked ids), $user->role_id or $user->role.
Why the closure lives in a provider and not the config.
php artisan config:cacheserializes the config array, and a closure in there breaks that command for the whole application — not just this package. Soadmin_resolveraccepts only serializable callables, and closures are registered in code.
With none of the three configured, nobody is an admin. That is deliberate: the failure mode of a half-finished setup should be a locked door, not an open one.
Finer-grained rules
The middleware answers "can you administer documentation at all?". For anything more specific — authors editing only their own articles, a separate publishing role — publish the policies and override them:
php artisan vendor:publish --tag=docs-module-policies
// app/Providers/AppServiceProvider.php Gate::policy(\Databis\DocsModule\Models\DocArticle::class, \App\Policies\DocArticlePolicy::class);
Your provider boots after the package's, so this wins with no opt-out flag.
Configuration
Everything below lives in config/docs-module.php. Only the entries worth
explaining are listed; the file itself is commented throughout.
Routes and access
'route_prefix' => 'api/docs', 'middleware' => ['api'], // all routes 'read_middleware' => [], // public documentation 'write_middleware' => ['auth:sanctum', 'docs.admin'], // editing
Public docs with authenticated editing is the default. For an internal
handbook, add auth:sanctum to read_middleware.
SubstituteBindings is applied by the package itself and does not need to be in
your middleware list. Every read route binds by slug, so without it the
controllers would receive raw strings.
Set 'register_routes' => false to declare the routes yourself. The docs.admin
middleware alias stays registered either way.
Locales
'default_locale' => 'es',
Article slugs are unique per (category, locale), so the same article can exist
translated at the same URL. Read endpoints take ?locale=; without it they fall
back to this value.
The
localecolumn has a database default of'es', set in the articles migration. If your base language is different, change it there before your firstmigrate.
Table prefix
'table_prefix' => 'doc_',
Settle this before the first migrate. Changing it afterwards means renaming
tables by hand.
Content security
'content' => [ 'allowed_nodes' => ['doc', 'paragraph', 'text', 'heading', /* ... */], 'allowed_marks' => ['bold', 'italic', 'link', /* ... */], 'allowed_link_schemes' => ['http', 'https', 'mailto', 'tel'], 'max_depth' => 50, ],
Incoming ProseMirror JSON is sanitized against these lists on save, in the
model, not in a form request. A direct POST to the API never touches your
editor, so the editor's schema cannot be the enforcement point.
If you add a TipTap extension on the frontend, add its node type here, or content using it will be saved stripped.
Images
'images' => [ 'max_size' => 4096, // KB 'allowed_mimes' => ['jpg', 'jpeg', 'png', 'gif', 'webp'], 'convert_to_webp' => true, 'max_width' => 1920, 'orphan_ttl_days' => 7, ],
SVG is excluded on purpose: it is an XSS vector with <script> inside. Enable
it only if you sanitize it yourself.
The upload response carries both the internal path and an absolute url
(https://example.test/storage/docs-images/2026/08/xxxx.webp). Articles store
the path, and the frontend renders it under /storage by default, so run
php artisan storage:link for the public disk. If storage is served from
somewhere else, point the frontend's storageBaseURL at it.
Conversion and downscaling need intervention/image, which is suggested rather
than required — without it the original file is stored untouched instead of the
upload failing.
composer require intervention/image
Housekeeping on save
Every save of an article settles its images immediately:
- Images the content references are attached to it, filling in the
doc_article_idthe editor could not know when it uploaded them. An image already attached to another article is left alone. - Images the previous version showed and the new one does not are deleted, row and file, unless another article still shows them. Drafts, soft-deleted and role-restricted articles all count as "still shows".
Restoring a revision counts as a save and is cleaned up the same way.
Revisions deliberately do not count as a reference. A revision holding the
outgoing content is written immediately before every update, so honouring them
would mean never deleting anything. The consequence is worth knowing: restoring
an old revision can come back with pictures that are gone. docs:prune-images
has always taken the same view — this only changes when the file goes, not
whether.
An upload that never made it into any article is not touched here: nothing on
the server can tell it apart from one being written into an article right now.
The editor can, though — it knows what it uploaded — and @databis/docs-module
deletes those through DELETE /images/{id} when the editor is closed. For
everything that survives both (a closed tab, a lost connection, a client that
does not do this), docs:prune-images and orphan_ttl_days are the backstop.
Search
'search' => ['driver' => 'fulltext', 'min_length' => 3],
fulltext uses a FULLTEXT index over (title, content_text) on MySQL and
PostgreSQL; like is the degraded path for SQLite. The index is only created
when the driver supports it.
There is no scout driver. Wiring Scout in means putting Searchable on the
model and owning its index lifecycle, which is your decision, not a package's.
If you already run Scout, bind your own implementation:
$this->app->bind( \Databis\DocsModule\Support\ArticleSearch::class, \App\Docs\ScoutArticleSearch::class );
Endpoints
All paths are relative to route_prefix.
Read
| Method | Path | Notes |
|---|---|---|
GET |
/categories |
Full tree, not paginated |
GET |
/categories/{slug} |
|
GET |
/categories/{slug}/articles |
Paginated, no bodies; admins may request ?drafts=1 |
GET |
/categories/{slug}/articles/{slug} |
With body |
GET |
/articles |
Flat listing, ?category_id= |
GET |
/search?q= |
Returns snippets |
GET |
/me/capabilities |
What the current user may do |
Every read route is filtered by role — see Visibility by role.
Write
| Method | Path | Notes |
|---|---|---|
POST |
/categories |
|
PUT/DELETE |
/categories/{id} |
Delete refuses a non-empty category with 422 |
POST |
/categories/reorder |
{"ids": [3,1,2]} |
POST |
/articles |
|
PUT/DELETE |
/articles/{id} |
See optimistic locking below |
POST |
/articles/reorder |
|
PATCH |
/articles/{id}/publish |
|
GET |
/articles/{id}/preview |
Drafts included |
GET |
/articles/{id}/revisions |
|
GET |
/articles/{id}/revisions/{id} |
|
POST |
/articles/{id}/revisions/{id}/restore |
|
POST |
/images |
multipart, field file |
DELETE |
/images/{id} |
Removes the file too |
GET |
/roles |
Options for the visibility dropdown |
Read routes bind by slug, because those URLs get shared. Write routes bind
by id, because a slug changes when someone renames an article and an
in-flight PUT should not fail for that.
Drafts
Unpublished content is hidden by a global scope. An anonymous request for a draft gets 404, not 403 — a 403 would confirm the article exists. Admins bind drafts through the same URLs.
Optimistic locking
PUT /articles/{id} requires an updated_at field: the value you received
with the article. If it no longer matches, the response is 409 with the
current version in current, so your editor can show a conflict instead of
overwriting a colleague's work.
{ "title": "…", "content": { "type": "doc", "…": "…" }, "updated_at": "2026-07-28T09:12:44+00:00" }
Capabilities
Rather than handing your frontend an isAdmin flag at boot — which duplicates
your role logic in two codebases and drifts — every article and category carries
its own can block, and GET /me/capabilities answers globally.
{ "data": { "id": 12, "title": "Installation",
"can": { "update": true, "delete": false, "publish": false } } }
It is a hint for rendering. Every write is authorized again server-side.
Visibility by role
Off by default. Switched on, the editor grows a multiple select naming the roles an article or a category is for, and everything readers see is filtered by it: listings, the category tree, single articles and search.
DOCS_ROLES_ENABLED=true
Two rules are fixed and not configurable:
- A document with no roles assigned is visible to everyone. Switching the feature on therefore changes nothing until you start assigning roles.
- Whoever may edit the documentation always sees all of it, whatever their own role — otherwise nobody could review what they publish for other people.
A reader who does not qualify gets 404, the same as for a draft.
The package does not know your role system, so the dropdown is populated from one of two sources. Either a roles table of yours:
DOCS_ROLES_TABLE=roles DOCS_ROLES_VALUE_COLUMN=id # stored against the document DOCS_ROLES_LABEL_COLUMN=name # shown in the dropdown
…or, with no table, the distinct values of a column on your users table:
DOCS_ROLES_USERS_TABLE=users DOCS_ROLES_USERS_COLUMN=role
Whatever value_column / users_column yields is both what gets stored and
what is matched against the reader. Their own roles are read the way
DocsAdminResolver already reads them: a roles() relation first, then
role_id, then role.
Assignments live in doc_visibility_roles and travel through the API as
roles on write, visible_roles on read:
{ "title": "Payroll", "content": { "…": "…" }, "roles": ["2", "3"] }
Omitting roles leaves the assignment untouched — a client written before this
existed cannot accidentally make a restricted article public. Send [] to
deliberately open it back up.
Maintenance commands
Add these to your scheduler:
// routes/console.php Schedule::command('docs:prune-images')->weekly(); Schedule::command('docs:prune-revisions')->daily();
docs:prune-images deletes uploads no article references any more. Saving
an article already collects whatever that save dropped (see
Housekeeping on save); what is left for this command is
the upload that never made it into any article at all.
It does not work by looking for a null doc_article_id. The editor uploads
images before the article is saved, so that column is null for most images that
are perfectly in use — pruning by it would be destructive. Instead the command
walks every article's document (drafts and soft-deleted ones included) and
collects the image nodes. Anything younger than orphan_ttl_days is spared
regardless.
php artisan docs:prune-images --dry-run
docs:prune-revisions trims history to revisions.keep_last. The API
already prunes inline after each save; this clears the backlog from before the
setting existed or was lowered.
docs:reindex rebuilds content_text, the plain text the search index
covers. Run it after importing content written directly to the database, or
after changing the extractor. It does not touch updated_at, so it will not
invalidate the locking token every open editor is holding.
php artisan docs:reindex --sanitize # also re-applies a tightened allow list
Translations
Messages ship in English and Spanish.
php artisan vendor:publish --tag=docs-module-lang
Frontend companion
@databis/docs-module speaks to this package over HTTP. Because they
version independently, the major versions are kept in lockstep: any breaking
change to the API contract bumps both, even if one of them has no code change.
| npm | composer |
|---|---|
^1.0 |
^1.0 |
If the frontend is served from another origin, configure config/cors.php, and
add the origin to stateful_domains if you use Sanctum's SPA mode.
For example, with Laravel on 127.0.0.1:8000 and Vite on
127.0.0.1:5173:
SANCTUM_STATEFUL_DOMAINS=127.0.0.1:5173 SESSION_SECURE_COOKIE=false
// config/cors.php 'paths' => ['api/*', 'sanctum/csrf-cookie'], 'allowed_origins' => ['http://127.0.0.1:5173'], 'allowed_headers' => ['*'], 'supports_credentials' => true,
The npm client includes cookies, reads Laravel's URL-encoded XSRF-TOKEN
cookie and sends it as X-XSRF-TOKEN on write requests. The SPA must obtain a
fresh cookie from /sanctum/csrf-cookie before its authenticated workflow.
Use the same hostname on both sides; localhost and 127.0.0.1 do not share
cookies.
Development
The suite runs against MySQL/MariaDB, not SQLite, on purpose: the FULLTEXT index and MySQL's treatment of NULLs inside unique indexes can only be verified on the real engine.
mysql -u root -e "CREATE DATABASE docs_module_test CHARACTER SET utf8mb4"
composer install
vendor/bin/phpunit
Connection settings live in phpunit.xml.
SearchTestandConfigurationTestuseDatabaseMigrationsrather thanRefreshDatabase. InnoDB defers FULLTEXT index updates until commit, andRefreshDatabasewraps each test in a transaction that is always rolled back, soMATCH()would find nothing however correct the query is.
vendor/bin/pint