Search by

databis / laravel-docs-module

databis

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

Package info

github.com/databisbcn/databis_doc_laravel

pkg:composer/databis/laravel-docs-module

Statistics

Installs: 12

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.2 2026-09-03 14:30 UTC

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

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_id the 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.

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