muhammadmahedihasan / laravel-user-manual
Markdown-based in-app user manual for Laravel applications
Package info
github.com/MuhammadMahediHasan/laravel-user-manual
pkg:composer/muhammadmahedihasan/laravel-user-manual
Requires
- php: ^8.2
- ext-intl: *
- illuminate/contracts: ^10.0|^11.0|^12.0|^13.0
- illuminate/filesystem: ^10.0|^11.0|^12.0|^13.0
- illuminate/routing: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- illuminate/view: ^10.0|^11.0|^12.0|^13.0
- league/commonmark: ^2.0
- mpdf/mpdf: ^8.2
Requires (Dev)
- illuminate/auth: ^10.0|^11.0|^12.0|^13.0
- illuminate/cache: ^10.0|^11.0|^12.0|^13.0
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- larastan/larastan: ^2.9|^3.0
- laravel/pint: ^1.24
- orchestra/testbench: ^8.22|^9.0|^10.0|^11.0
- pestphp/pest: ^2.36|^3.0|^4.0
- pestphp/pest-plugin-laravel: ^2.4|^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A markdown-based in-app user manual and help center for Laravel applications with multi-locale support, permission-aware navigation, and dynamic PDF exports.
Contents
- Why?
- Features
- Quick Start
- Installation
- Content Structure
- Configuration
- PDF Export
- Permissions
- AI Authoring
- Why Laravel User Manual?
- Commands
- Upgrading to 2.0
- Development
- Contributing
- Security
- Changelog
- License
Why?
SaaS applications, ERP systems, and internal admin tools frequently require an integrated user manual, help center, or knowledge base. Building a custom documentation engine often leads to unmaintained Blade views, hardcoded links, iframe widgets, or unmanaged access permissions for confidential guides.
Laravel User Manual solves this by turning plain Markdown files into a secure, responsive in-app documentation portal. Because your documentation lives directly inside your repository, it stays version-controlled, testable, and synchronized with your application code—without external SaaS subscriptions, database tables, or third-party widgets.
Use this package when you need to:
- Store version-controlled Markdown documentation directly inside your application repository.
- Serve localized, multi-language user guides and admin documentation.
- Restrict sensitive documentation pages to specific roles or permissions using your existing authorization logic.
- Export styled, branded PDF user manuals for offline distribution.
- Deliver a fast, in-app knowledge base without database tables or external service dependencies.
Features
Documentation & Content
- Markdown-First: Write documentation using standard GitHub-Flavored Markdown (GFM).
- Dynamic Navigation: Build clean multi-level sidebar navigation from a simple
navigation.mdfile. - Version Management: Organize documentation sets by product release versions (e.g.,
1.0,2.0).
Localization & Multi-Language
- Multi-Locale Routing: Serve language-specific documentation out of the box (e.g.,
/user-manual/en/...,/user-manual/bn/...). - Locale-Aware Formatting: Automatically format page numbers, TOC indexes, and dates using localized numeral scripts (such as Bengali digits via PHP
intl).
Security & Access Control
- Permission Mapping: Control access to individual pages or sub-sections via Laravel permissions or roles.
- Super Admin Bypass: Assign roles that automatically bypass documentation restrictions.
- Custom Access Resolvers: Extend authorization logic using a closure-based facade callback.
- Any Role System: Works with a
rolecolumn, an enum, your own roles table, spatie/laravel-permission, Bouncer, or no roles at all. No role package required. - Safe CommonMark Defaults: Protect your app against stored XSS by automatically escaping raw HTML and blocking unsafe
javascript:links by default.
Performance & Caching
- Automatic Cache Invalidation: Cache rendered Markdown and navigation trees using file modification timestamps (
filemtime), invalidating instantly when.mdfiles are edited. - Zero Database Load: Deliver cached documentation pages with sub-5ms response times; full PDFs are streamed from storage, never rendered on request.
PDF Export
- Single Page & Full Manual: Download one page (rendered instantly) or the whole manual (pre-generated per role, streamed from storage).
- Role-Aware Full Manual: One full PDF per role, regenerated in the background when a role changes. Plug in any role system.
- Branded Layouts: Customize cover pages, tables of contents, headers, and footers using Blade templates.
- Custom Typography: Support custom TrueType fonts for non-Latin character sets via mPDF integration.
Quick Start
Get up and running with your first documentation page in a few simple steps:
-
Install the package:
composer require muhammadmahedihasan/laravel-user-manual
-
Publish assets and configuration:
php artisan vendor:publish --tag=user-manual
(Demo documentation stubs for
navigation.mdandintroduction.mdare automatically generated on first boot). -
Configure navigation (
resources/user-manual/1.0/en/navigation.md):- [Introduction](/user-manual/en/introduction)
-
Write documentation (
resources/user-manual/1.0/en/introduction.md):# Introduction Welcome to the application user manual. -
View in browser: Navigate to
/user-manualin your browser. -
(Optional) Pre-generate the full manual PDF:
php artisan user-manual:pdf
Single page PDFs work right away. The "Full PDF" button serves files made by this command (see Full Manual PDFs per Role).
Installation
System Requirements
- PHP:
^8.2(Laravel 13 requires PHP^8.3) - Laravel Framework:
^10.0,^11.0,^12.0, or^13.0 - Extensions:
ext-intl
Install Package
composer require muhammadmahedihasan/laravel-user-manual
Publish Package Resources
Publish configuration files, Blade views, language translations, assets, and documentation stubs:
php artisan vendor:publish --tag=user-manual
To publish components selectively:
php artisan vendor:publish --tag=user-manual-config php artisan vendor:publish --tag=user-manual-views php artisan vendor:publish --tag=user-manual-lang php artisan vendor:publish --tag=user-manual-assets php artisan vendor:publish --tag=user-manual-docs
Content Structure
Documentation files are organized inside resources/user-manual/{version}/{locale}/:
resources/user-manual/
└── 1.0/
├── en/
│ ├── navigation.md
│ ├── introduction.md
│ └── user-management.md
└── bn/
├── navigation.md
├── introduction.md
└── user-management.md
Title Extraction
The renderer automatically extracts the first # H1 header in the Markdown file for page titles and browser header tags. If no # H1 header is found, it formats the filename slug into words.
Navigation Hierarchy
Sidebar navigation is driven by navigation.md in each locale directory. Indent items by two spaces to create nested sub-sections:
- [Introduction](/user-manual/en/introduction) - [User Management](/user-manual/en/user-management) - [Roles & Permissions](/user-manual/en/roles-and-permissions) - [External Help](https://example.com/support)
Configuration
Publishing config/user-manual.php provides control over routing, security, caching, and layout options:
return [ 'route_prefix' => 'user-manual', 'route_name' => 'user-manual.show', 'middleware' => ['web', 'auth'], 'version' => '1.0', 'content_path' => resource_path('user-manual'), 'default_page' => 'introduction', 'locales' => ['en'], 'default_locale' => 'en', 'cache_ttl' => 3600, 'cache_prefix' => 'user-manual', // Merged over safe defaults (html_input: escape, allow_unsafe_links: false). // Override only if you trust all markdown authors. 'commonmark' => [], // Access control (see Permissions) 'access_checker' => \MuhammadMahediHasan\UserManual\Access\DefaultAccessChecker::class, 'super_admin_roles' => [], 'permission-mapper' => [], // Roles: one full manual PDF is pre-generated per role (see PDF Export) 'roles' => [ 'resolver' => \MuhammadMahediHasan\UserManual\Roles\DefaultRoleResolver::class, 'list' => [], 'model' => null, 'name' => 'name', 'permissions' => 'permissions', 'permission_name' => 'name', 'scope' => null, 'user_roles' => 'roles', ], 'pdf' => [ 'enabled' => true, 'page' => ['enabled' => true], 'full' => ['enabled' => true, 'pending_view' => 'user-manual::pdf.pending', 'retry_after' => 60], 'disk' => 'local', 'directory' => 'user-manual/pdfs', 'queue' => ['connection' => null, 'queue' => null, 'timeout' => 600, 'tries' => 1, 'unique_for' => null], // paper format, margins, fonts, cover/header/footer views… ], ];
Multiple manuals
Leave manuals empty for a single manual. That keeps the root content_path, route_prefix, locales, and route names (user-manual.show, user-manual.pdf.page, user-manual.pdf.full).
Set manuals when one application serves more than one manual. Each entry inherits the root config and overrides only the keys it sets. A manual is a content tree plus a route. Locales stay folders inside that tree ({content_path}/{version}/{locale}/{page}.md), so each panel can have its own en and bn pages.
The package does not require a domain. An application that splits panels by path uses a different route_prefix for each manual:
'manuals' => [ 'staff' => [ 'content_path' => resource_path('user-manual/staff'), 'route_prefix' => 'manual', 'locales' => ['en', 'bn'], 'middleware' => ['web', 'auth'], ], 'partner' => [ 'content_path' => resource_path('user-manual/partner'), 'route_prefix' => 'partner/manual', 'locales' => ['en', 'bn'], 'middleware' => ['web', 'auth:partner'], 'auth_guards' => ['partner'], ], ],
An application that serves the same path on two hosts passes Laravel route-group attributes through route. domain is only one of those attributes. prefix, where, or middleware work the same way. If both middleware and route.middleware are set, the route-group list is registered.
'manuals' => [ 'portal' => [ 'content_path' => resource_path('user-manual/portal'), 'route_prefix' => 'user-manual', 'locales' => ['en', 'bn'], 'route' => [ 'domain' => env('PORTAL_DOMAIN', 'abc.com'), 'middleware' => ['web', 'auth'], ], ], 'admin' => [ 'content_path' => resource_path('user-manual/admin'), 'route_prefix' => 'user-manual', 'locales' => ['en', 'bn'], 'route_name' => 'admin.user-manual.show', 'cache_prefix' => 'user-manual.admin', 'route' => [ 'domain' => env('ADMIN_DOMAIN', 'x.abc.com'), 'middleware' => ['web', 'auth:admin'], ], 'auth_guards' => ['admin'], ], ],
Named manuals that omit route_name use {name}.user-manual.show. PDF routes follow the same pattern ({name}.user-manual.pdf.page and {name}.user-manual.pdf.full). A named manual that omits cache_prefix suffixes the root prefix with its name so cached pages and PDFs do not collide. user-manual:cache and user-manual:clear-cache accept an optional manual name and otherwise run for every manual.
navigation.md links use that manual's own public prefix. Set set_locale_on_visit on a manual when visiting it should not write the shared locale session key.
CommonMark options
Markdown is rendered with League CommonMark (GitHub-flavored). Package defaults escape raw HTML and block unsafe links. Values under commonmark are merged on top of those defaults and passed to GithubFlavoredMarkdownConverter:
'commonmark' => [ 'html_input' => 'allow', 'allow_unsafe_links' => true, ],
Only do this if you trust every markdown author; allowing raw HTML increases stored XSS risk because rendered content is output unescaped in the manual view.
Customizing UI & Host Application Styles
The package publishes pre-scoped assets (user-manual.css and user-manual.js) to public/vendor/user-manual/. To layer host application styles (such as Tailwind CSS entrypoints) on top of the manual layout, configure ui.vite_assets:
'ui' => [ 'logo_url' => '/images/logo.svg', 'home_url' => '/', 'back_url' => '/dashboard', 'vite_assets' => ['resources/css/app.css'], 'primary_color' => '#FF2D20', ],
PDF Export
Laravel User Manual provides single-page and full manual PDF exports using mPDF.
Export Routes
- Single Page PDF:
/user-manual/{locale}/{page}/pdf(user-manual.pdf.page): rendered on request, nothing stored. - Full Manual PDF:
/user-manual/{locale}/export/pdf(user-manual.pdf.full): the pre-generated PDF of the viewer's role.
PDF Features
- Structured Indexing: Automatically compiles all accessible documentation pages into a single PDF document with a table of contents.
- Locale-Aware Numerals: Adapts pagination (e.g.
1/10vs১/১০) to the active locale. - Memory Efficiency: Chunks large HTML streams during document assembly.
Custom Fonts & Typography
To support custom fonts (e.g. non-Latin scripts), define font paths under pdf.fonts in config/user-manual.php:
'pdf' => [ 'default_font' => 'kalpurush', 'fonts' => [ 'font_dirs' => [resource_path('fonts')], 'font_data' => [ 'kalpurush' => [ 'R' => 'kalpurush.ttf', 'B' => 'kalpurush.ttf', 'useOTL' => 0xFF, 'useKashida' => 75, ], ], ], ],
Customizing Covers, Headers & Footers
Publish views (php artisan vendor:publish --tag=user-manual-views) to customize PDF templates in resources/views/vendor/user-manual/pdf/:
cover.blade.php: Cover page header, subtitle, logo, and generation date.header.blade.php: Document running headers.footer.blade.php: Document running footers and page numbers.
Full Manual PDFs per Role
Rendering a whole manual takes long, so the full PDF is never rendered during a web request. It is generated ahead of time, once per role, and stored on a filesystem disk. A download only looks up the viewer's role and streams that file.
user-manual:pdf / queued job ──► disk: user-manual/pdfs/{manual}/{version}/{locale}/
manifest.json role key → file
{signature}-{hash}.pdf one per page set
download ──► role of user ──► manifest ──► stream file
- Roles that can read the same pages share one file.
- When content or a role changes, a new file is written first; the role is switched to it, then the old file is deleted if no other role uses it. Users never hit a missing file.
- If a role has no PDF yet, the first download queues a job (only once: the job is unique per manual, locale and role) and answers 202 with a "your PDF is being prepared" page, or JSON
{"status": "generating"}forAccept: application/json. - If the markdown changed since the PDF was generated, the old PDF is still served while a fresh one is queued. Only
.mdfiles are watched: after changing only images, runphp artisan user-manual:pdf --force.
1. Tell the package about your roles
The package needs no role package. Pick the resolver that matches how your app stores roles, in the roles block of config/user-manual.php:
| Your app | Resolver | Config |
|---|---|---|
| No roles | Roles\DefaultRoleResolver (default) |
nothing: everyone gets one default PDF |
Roles in code: a users.role column or an enum |
Roles\ConfigRoleResolver |
list (role → profile), user_roles |
| Roles in a database table (your own table, spatie/laravel-permission, Bouncer, …) | Roles\EloquentRoleResolver |
model, name, permissions, permission_name, scope, user_roles |
| Anything else | your own Contracts\RoleResolver class, or closures |
An access profile is what a role may see, in the same terms as permission-mapper:
| Profile | Meaning |
|---|---|
[] |
signed-in user without permissions |
['perm_a', 'perm_b'] |
has these permissions |
['perm_a', 'roles' => ['Editor']] |
permissions plus role names |
'all' |
every page |
Roles in code
'roles' => [ 'resolver' => \MuhammadMahediHasan\UserManual\Roles\ConfigRoleResolver::class, 'user_roles' => 'role', // users.role: "admin", "editor", … (string or enum) 'list' => [ 'admin' => 'all', 'editor' => ['post_edit', 'post_publish'], 'member' => [], ], ],
Roles in a table
'roles' => [ 'resolver' => \MuhammadMahediHasan\UserManual\Roles\EloquentRoleResolver::class, 'model' => App\Models\Role::class, 'name' => 'name', // role name attribute 'permissions' => 'permissions', // relation of permission models, an array/JSON column, or null 'permission_name' => 'name', // name attribute of permission models 'scope' => null, // invokable class (Builder $query, ManualDefinition $manual), e.g. to filter a guard or tenant 'user_roles' => 'roles', // where a user's role(s) are: 'roles' (many), 'role' (belongsTo), 'role_id' (column) ],
With spatie/laravel-permission the defaults already fit: set model to your role model (and add a scope class with where('guard_name', 'web') if you use several guards). A users table with role_id and a JSON abilities column on roles works with 'permissions' => 'abilities', 'user_roles' => 'role'.
Role keys are the model keys. For needs the config can't express, extend EloquentRoleResolver and override query(), permissionsFor() or roleFor().
Your own resolver
interface RoleResolver { // role key => access profile, for every role public function roles(ManualDefinition $manual): iterable; // the user's role key (null = "default") public function roleFor(Authenticatable $user, ManualDefinition $manual): int|string|null; }
Set it as roles.resolver, or register closures in a service provider:
use MuhammadMahediHasan\UserManual\Facades\UserManual; UserManual::resolveRolesUsing( roles: fn (ManualDefinition $manual) => ['admin' => 'all', 'staff' => ['reports_view']], roleFor: fn ($user, ManualDefinition $manual) => $user->is_admin ? 'admin' : 'staff', );
roles.* (like every key) can be overridden per entry in manuals.
2. Generate the PDFs
php artisan user-manual:pdf # every manual, locale and role (add to your deploy) php artisan user-manual:pdf staff --locale=en # one manual / locale php artisan user-manual:pdf --role=3 --role=7 # some roles php artisan user-manual:pdf --queue # dispatch jobs instead of rendering here php artisan user-manual:pdf --force # render again even if up to date php artisan user-manual:pdf --unlock --queue # free locks left by killed jobs, then queue again php artisan user-manual:pdf-clear # delete all stored full PDFs
Without --queue the command renders in its own process (good for deploys) and removes files and manifest entries of roles that no longer exist.
3. Keep them up to date when roles change
Call these wherever your app creates, updates or deletes roles (after the transaction commits):
UserManual::regenerateRole($role->id); // queue new PDFs; the old ones are served until they are ready UserManual::forgetRole($role->id); // role deleted: remove its PDFs UserManual::releaseLocks($role->id); // free "already queued" locks left by a killed job
Both accept an optional manual name as second argument.
Configuration
'pdf' => [ 'page' => ['enabled' => true], 'full' => [ 'enabled' => true, 'pending_view' => 'user-manual::pdf.pending', // shown with HTTP 202 'retry_after' => 60, // Retry-After header (seconds) ], 'disk' => env('USER_MANUAL_PDF_DISK', 'local'), // any filesystem disk, e.g. s3 'directory' => 'user-manual/pdfs', 'queue' => [ 'connection' => env('USER_MANUAL_PDF_QUEUE_CONNECTION'), // null = default 'queue' => env('USER_MANUAL_PDF_QUEUE'), 'timeout' => 600, 'tries' => 1, 'unique_for' => null, // how long a waiting job blocks duplicates; null = timeout + 60 'job' => \MuhammadMahediHasan\UserManual\Jobs\GenerateFullManualPdf::class, // or a subclass ], ],
Queue: use a real queue (
database,redis, …) with a worker in production, and give the worker the job's timeout (php artisan queue:work --timeout=600;queue:listendefaults to 60 seconds, too short for big manuals). On thesyncconnection, jobs started from a web request (a download,regenerateRole()after saving a role) run after the response is sent, with the job timeout as time limit, so the request itself stays fast.
"Queued" but nothing in the
jobstable? Each job is unique per manual, locale and role. While one is waiting or running, dispatching it again does nothing. If a worker or request was killed mid-job, its lock stays untilunique_forpasses (default: timeout + 60 seconds). Release it right away withphp artisan user-manual:pdf --unlock(user-manual:pdf-clearreleases locks too). Also check that no worker such asqueue:listenis already taking jobs off the table.
Events
Events\FullPdfGenerated(manual,locale,roleKey,path,reused)Events\FullPdfGenerationFailed(manual,locale,roleKey,exception)
Note: Large full-manual PDFs with images are memory-heavy. If generation fails with a memory error, raise
memory_limitfor the CLI / queue worker.
Tip: keep screenshots lean. Every role's PDF embeds its own copy of each image, so image size multiplies. PNG screenshots with an unused transparency channel are especially slow and large in mPDF. Optimizing them typically makes PDFs about 3× smaller and much faster to build, with no visible change:
mogrify -background white -alpha remove -alpha off public/path/to/images/*.png # drop unused transparency (ImageMagick) pngquant --quality=85-98 --skip-if-larger --strip --ext .png --force public/path/to/images/*.png optipng -quiet -o2 -strip all public/path/to/images/*.png php artisan user-manual:pdf --forceOnly drop transparency from images that don't use it (
identify -format '%[opaque]'printsTruefor those).
Examples
A full setup with a roles table
// config/user-manual.php 'super_admin_roles' => ['Super Admin'], 'permission-mapper' => [ 'introduction' => '*', 'reports' => ['report_view'], 'billing' => ['billing_manage'], 'system-settings' => ['roles' => ['Admin']], ], 'roles' => [ 'resolver' => \MuhammadMahediHasan\UserManual\Roles\EloquentRoleResolver::class, 'model' => App\Models\Role::class, 'user_roles' => 'roles', ], 'pdf' => [ 'disk' => 's3', 'queue' => ['connection' => 'redis', 'queue' => 'pdf'], ],
php artisan user-manual:pdf # on deploy
php artisan queue:work redis --queue=pdf --timeout=600
Each role gets a PDF with only the pages it may read. A role with report_view gets "Reports" but not "Billing", and Super Admins get everything.
Filter roles with a scope class (a guard, a tenant, active roles only):
namespace App\UserManual; use Illuminate\Database\Eloquent\Builder; use MuhammadMahediHasan\UserManual\Support\ManualDefinition; class WebRolesOnly { public function __invoke(Builder $query, ManualDefinition $manual): void { $query->where('guard_name', 'web'); } }
'roles' => [ 'resolver' => \MuhammadMahediHasan\UserManual\Roles\EloquentRoleResolver::class, 'model' => App\Models\Role::class, 'scope' => App\UserManual\WebRolesOnly::class, ],
Extend EloquentRoleResolver when a role's permissions depend on more than the config can say, e.g. permissions stored per tenant on the pivot, and one manual per tenant:
namespace App\UserManual; use App\Models\Role; use Illuminate\Database\Eloquent\Builder; use MuhammadMahediHasan\UserManual\Roles\EloquentRoleResolver; use MuhammadMahediHasan\UserManual\Support\ManualDefinition; class TenantRoleResolver extends EloquentRoleResolver { protected function query(ManualDefinition $manual): Builder { // `tenant` is a custom key on the manual entry in config/user-manual.php $tenant = $manual->resolve('tenant', 'default'); return Role::query()->with([ 'permissions' => fn ($query) => $query->wherePivot('tenant', $tenant), ]); } }
'roles' => ['resolver' => App\UserManual\TenantRoleResolver::class, 'model' => App\Models\Role::class], 'manuals' => [ 'shop' => ['content_path' => resource_path('user-manual/shop'), 'route_prefix' => 'shop/help', 'tenant' => 'shop'], 'crm' => ['content_path' => resource_path('user-manual/crm'), 'route_prefix' => 'crm/help', 'tenant' => 'crm'], ],
Write your own resolver, e.g. for roles from an external API or a hard-coded enum:
namespace App\UserManual; use App\Enums\Role; use Illuminate\Contracts\Auth\Authenticatable; use MuhammadMahediHasan\UserManual\Contracts\RoleResolver; use MuhammadMahediHasan\UserManual\Support\ManualDefinition; class EnumRoleResolver implements RoleResolver { public function roles(ManualDefinition $manual): iterable { foreach (Role::cases() as $role) { yield $role->value => $role === Role::Owner ? 'all' : $role->permissions(); } } public function roleFor(Authenticatable $user, ManualDefinition $manual): ?string { return $user->role?->value; // null → "default" } }
If you add your own default role, list it in roles() too. Any key roleFor() returns must exist there.
Regenerate PDFs when roles change, from an observer:
namespace App\Observers; use App\Models\Role; use MuhammadMahediHasan\UserManual\Facades\UserManual; class RoleObserver { public bool $afterCommit = true; public function saved(Role $role): void { UserManual::regenerateRole($role->getKey()); } public function deleted(Role $role): void { UserManual::forgetRole($role->getKey()); } }
Model events don't fire when only a pivot changes (e.g. $role->permissions()->sync(...)). In that case call UserManual::regenerateRole() right after the sync, or wrap it with DB::afterCommit(fn () => UserManual::regenerateRole($role->id)) inside a transaction. You can also refresh one manual only: UserManual::regenerateRole($role->id, 'crm').
React to generation results:
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; use MuhammadMahediHasan\UserManual\Events\FullPdfGenerated; use MuhammadMahediHasan\UserManual\Events\FullPdfGenerationFailed; Event::listen(fn (FullPdfGenerated $event) => Log::info("Manual PDF ready: {$event->manual}/{$event->locale} for role {$event->roleKey}")); Event::listen(fn (FullPdfGenerationFailed $event) => Log::error('Manual PDF failed', [ 'manual' => $event->manual, 'role' => $event->roleKey, 'error' => $event->exception?->getMessage(), ]));
Customize the job (middleware, backoff, more memory):
namespace App\Jobs; use Illuminate\Queue\Middleware\WithoutOverlapping; use MuhammadMahediHasan\UserManual\Jobs\GenerateFullManualPdf; use MuhammadMahediHasan\UserManual\Pdf\FullPdfGenerator; class GenerateManualPdf extends GenerateFullManualPdf { public function middleware(): array { return [(new WithoutOverlapping('user-manual-pdf'))->releaseAfter(30)]; } public function handle(FullPdfGenerator $generator): void { ini_set('memory_limit', '1G'); parent::handle($generator); } }
'pdf' => ['queue' => ['job' => App\Jobs\GenerateManualPdf::class]],
Custom "being prepared" page: copy resources/views/vendor/user-manual/pdf/pending.blade.php (published with --tag=user-manual-views) or point to your own view. It receives $locale, $message and $retryAfter:
'pdf' => ['full' => ['pending_view' => 'help.pdf-pending', 'retry_after' => 30]],
Download from JavaScript: request with Accept: application/json to tell a 202 apart from the file:
async function downloadManual(url) { const response = await fetch(url, { headers: { Accept: 'application/json' } }); if (response.status === 202) { const { message, retry_after } = await response.json(); alert(message); // "The full manual PDF is being generated…" return setTimeout(() => downloadManual(url), retry_after * 1000); } window.open(url, '_blank'); }
Permissions
Restrict access to specific documentation pages by setting rules in permission-mapper inside config/user-manual.php.
Permission Mapping Examples
'permission-mapper' => [ // Accessible to all authenticated users 'introduction' => '*', // Requires any one of the specified permissions 'financials' => ['view_financials', 'manage_billing'], // Requires any one of the specified roles 'system-admin' => ['roles' => ['Super Admin', 'Admin']], ],
- Pages not specified in
permission-mapperdefault to accessible for all authenticated users. - Unauthorized pages are automatically removed from navigation menus and PDF exports.
- Roles specified in
super_admin_rolesbypass all permission checks.
How permissions and roles are checked
No role package is required. The default Access\DefaultAccessChecker:
- Permissions:
$user->can($permission), Laravel's Gate. Anything registered with the Gate counts:Gate::define(), policies,Gate::before(), or a role package hooked into the Gate. - Roles:
$user->hasRole($roles)if your user model has that method. Otherwise it uses the role names found atroles.user_roles(defaultroles): a relation of role models (theirroles.nameattribute), one model (role), or a plain column or enum (role).
To check differently, implement Contracts\AccessChecker and set it in the config:
'access_checker' => App\UserManual\MyAccessChecker::class,
interface AccessChecker { public function hasPermission(Authenticatable $user, string $permission): bool; public function hasAnyRole(Authenticatable $user, array $roles): bool; }
Examples without extra config:
// users.role column holding "admin" / "editor" 'roles' => ['user_roles' => 'role'], 'permission-mapper' => ['settings' => ['roles' => ['admin']]], // users.role_id → roles table with a `title` column 'roles' => ['user_roles' => 'role', 'name' => 'title'],
A custom checker, e.g. permissions from a JSON column on the user and roles from a team membership:
namespace App\UserManual; use Illuminate\Contracts\Auth\Authenticatable; use MuhammadMahediHasan\UserManual\Contracts\AccessChecker; class TeamAccessChecker implements AccessChecker { public function hasPermission(Authenticatable $user, string $permission): bool { return in_array($permission, $user->permissions ?? [], true); } public function hasAnyRole(Authenticatable $user, array $roles): bool { return in_array($user->currentTeam?->pivot?->role, $roles, true); } }
The access checker is used only for real users on the web pages. Full PDFs are built from each role's profile in the role resolver, so a custom checker never changes what a role's PDF contains.
Custom Access Resolver
For complex authorization logic, register a callback using the UserManual facade:
use MuhammadMahediHasan\UserManual\Facades\UserManual; UserManual::resolveAccessUsing(function ($user, string $slug, array $requirements): ?bool { if ($user->isOwner()) { return true; } return null; // Fall back to standard permission mapping });
AI Authoring
This package ships an AI skill definition (SKILL.md) for AI coding assistants (such as Claude Code, Antigravity, or Cursor).
Supported Workflows
- Proactive Documentation: Instruct your AI agent to write or update user manual pages whenever a new user-facing feature is completed.
- Conventions Enforcement: Teaches AI assistants exact file placement (
resources/user-manual/{version}/{locale}/{slug}.md), slug validation ([a-z0-9-]+), header formatting,navigation.mdstructures, and permission mappings.
To enable, copy SKILL.md into your workspace skill directory (e.g., .claude/skills/laravel-user-manual-authoring.md or .agents/skills/laravel-user-manual-authoring/SKILL.md).
Why Laravel User Manual?
- Version-Controlled Docs: Keep your documentation inside your Git repository alongside application code.
- Zero External Dependencies: No monthly SaaS fees, third-party iframe widgets, or external API rate limits.
- Native Authorization: Restrict sensitive guides using your existing Laravel roles, permissions, and auth guards.
- Offline PDF Exports: Generate branded, single-page or full-manual PDFs directly from your Markdown source files.
- Multi-Language Support: Provide localized user manuals with locale-aware routing and numeral formatting out of the box.
- Zero Database Footprint: Runs entirely on file modification timestamps and application cache without database migrations.
- AI Authoring Ready: Includes an AI skill definition (
SKILL.md) so AI assistants can write and maintain docs automatically.
Commands
# Pre-render navigation and markdown HTML php artisan user-manual:cache # Clear cached navigation and rendered markdown php artisan user-manual:clear-cache # Pre-generate the full manual PDF of every role (see "Full Manual PDFs per Role") php artisan user-manual:pdf {manual?} {--role=*} {--locale=*} {--queue} {--force} {--unlock} # Delete the stored full manual PDFs (and release their job locks) php artisan user-manual:pdf-clear {manual?}
user-manual:cache clears first, then warms. Each command accepts an optional manual name.
Upgrading to 2.0
Version 2.0 changes how PDFs are made: full manuals are pre-generated per role instead of being rendered or cached on request. Most apps need four steps:
- Update the package
composer require muhammadmahedihasan/laravel-user-manual:^2.0
- Update a published
config/user-manual.php. Compare it with the package config (vendor:publish --tag=user-manual-config --forceoverwrites yours, so merge by hand):- remove
pdf.warm_full,pdf.warm_profilesandpdf.cache_path; - add
access_checker, therolesblock, andpdf.page,pdf.full,pdf.disk,pdf.directoryandpdf.queue. Missing keys fall back to the defaults: onedefaultPDF for everyone.
- remove
- Tell the package about your roles if pages are permission-restricted. See Full Manual PDFs per Role. Keep the PDFs current with
UserManual::regenerateRole()/forgetRole()where roles change. - Generate the PDFs on deploy and run a worker
php artisan user-manual:pdf php artisan queue:work --timeout=600
Also check:
-
Published views: if you published
show.blade.php, wrap its two PDF buttons in@if (Config::bool('user-manual.pdf.page.enabled', true))/@if (Config::bool('user-manual.pdf.full.enabled', true)). The newpdf/pending.blade.phpview is used from the package unless you publish it. -
Cache commands:
user-manual:cacheanduser-manual:clear-cacheonly handle markdown and navigation now. Useuser-manual:pdfanduser-manual:pdf-clearfor PDFs. -
Role checks:
['roles' => [...]]rules andsuper_admin_rolesno longer need ahasRole()method. Users without one are checked throughroles.user_roles. -
Code calling the PDF service:
1.x 2.0 PdfGeneratorService::generatePagePdf($locale, $page)pagePdf($locale, $page)PdfGeneratorService::generateFullPdf($locale)fullPdfResponse($request, $locale, $user)PdfGeneratorService::createMpdfInstance(),chunkHtml(),resolveImageSources()same methods on Pdf\PdfRendererwarmFullPdf(),fullPdfIndexKey(),forgetFullPdfCaches(),pdfCachePath(),forgetPdf(),flushPdfCacheFiles()removed: use user-manual:pdf,user-manual:pdf-clear,UserManual::regenerateRole()calculateMaxLastModified()contentHash($locale) -
Old files: the 1.x PDF cache files (
storage/app/user-manual/pdfs/*.b64) are no longer used and can be deleted.
Development
Run tests and static analysis from the package directory:
# Run unit and feature tests with Pest composer test # Check code formatting with Laravel Pint composer pint # Execute static analysis with PHPStan composer phpstan
Contributing
Contributions are welcome. Please ensure all pull requests include tests and pass Pint formatting (composer pint) and PHPStan analysis (composer phpstan).
Security
If you discover a security vulnerability within Laravel User Manual, please email the maintainer directly instead of opening a public issue. By default, Safe CommonMark settings escape raw HTML tags and strip javascript: URIs to prevent XSS vulnerabilities.
Changelog
Please see CHANGELOG.md for details on recent releases.
License
The MIT License (MIT). Please see LICENSE for more information.
