databis/laravel-docs-module

Documentation module for Laravel: nested categories, TipTap-backed articles, images and revisions, exposed as a REST API.

Maintainers

Package info

github.com/databisbcn/databis_doc_laravel

pkg:composer/databis/laravel-docs-module

Transparency log

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.1 2026-08-31 12:16 UTC

This package is auto-updated.

Last update: 2026-08-31 14:28:35 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:cache serializes the config array, and a closure in there breaks that command for the whole application — not just this package. So admin_resolver accepts 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 locale column has a database default of 'es', set in the articles migration. If your base language is different, change it there before your first migrate.

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.

SearchTest and ConfigurationTest use DatabaseMigrations rather than RefreshDatabase. InnoDB defers FULLTEXT index updates until commit, and RefreshDatabase wraps each test in a transaction that is always rolled back, so MATCH() 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.