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).
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
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 |
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 |
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.
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.
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
License
This project is licensed under CC BY-NC-SA 4.0 — Attribution-NonCommercial-ShareAlike. You may share and adapt this work for non-commercial purposes with attribution, under the same license. For commercial use, please contact Databis to obtain a commercial license.