Skip to content
Public template

About

Modular Laravel 13 + Inertia v3 + React 19 admin panel template — module contract, roles/permissions, passkeys, theming system, 100% coverage

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

68 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Atrium

tests

A modular admin panel and app template: Laravel 13 · Inertia v3 · React 19 · TypeScript · Tailwind v4 · shadcn/ui. A generic shell (auth, account settings, theming, the module contract) hosts modules, one folder per business domain. Strict by construction: PHPStan max, 100% line and type coverage, architecture tests, PostgreSQL and SQLite in CI.

Quick start

composer setup                   # composer install, .env, key, migrate, storage link, bun install, bun run build
php artisan admin:create-user    # first verified user with the `admin` role (syncs permissions first)
composer run dev                 # server, queue worker, logs (pail), Vite
  • Runtimes: PHP 8.5 runs natively or as docker compose run --rm app <cmd>. JS runs with Bun; never use npm.
  • First super-admin: no command creates one. Run php artisan db:seed --class=RoleSeeder (creates the super-admin role), then php artisan tinker --execute '\App\Models\User::where("email", "[email protected]")->first()->assignRole("super-admin");'.
  • Production: follow the checklist in "Deploying" below. .env.example and .env.production.example list and explain every setting.

For AI agents: how to work on this project

Sources of truth, in order:

  1. This README. Wins over everything below when they disagree.
  2. THEMING.md: the token and layout contract. docs/specs/ddd.md: the domain layer in depth. docs/specs/build-prompt.md: architecture and approved dependencies. docs/using-the-template.md: downstream workflow.
  3. CLAUDE.md: generated by Laravel Boost from .ai/guidelines/*.blade.php plus package guidelines. Edit .ai/guidelines, never CLAUDE.md. Its generic Laravel advice ("use php artisan make:", "create seeders") applies to the shell only. Module files come from make:module / make:aggregate or are written by hand inside the module; modules have factories, not seeders.
  4. Skills in .claude/skills (laravel-best-practices, wayfinder-development, fortify-development). .github/skills is the same set for Copilot.
  5. The reference modules. app-modules/Catalog is canonical: copy its shapes. Shop adds events, listeners, notifications, settings and an API. Users shows invitations, guest pages and a console command.

Before writing code, find the sibling that already does it and match it. Consistency beats novelty.

Generated files: never edit by hand.

File Regenerate with When
resources/js/types/generated.d.ts, typescript-transformer-manifest.json (committed) php artisan typescript:transform After any change to a Data class or an enum, in app/ or app-modules/
resources/js/{actions,routes,wayfinder} (git-ignored) php artisan wayfinder:generate --with-form, or automatically by bun run dev / bun run build After any route change, before bun run test:types. Needs a migrated database
CLAUDE.md php artisan boost:update --no-discover --ignore-skills After editing .ai/guidelines. It also refreshes Boost's own guideline text and boost.json to the installed Boost version: review the whole diff before committing
Module lines in bootstrap/providers.php make:module / module:remove Keep the exact Modules\X\Providers\XServiceProvider::class, form; both commands and ArchTest parse it
// @ddd-bindings, // @ddd-navigation, // @ddd-permissions in a new provider consumed by make:aggregate Leave them until make:aggregate has run

Commands without a TTY: pass --no-interaction. module:remove removes nothing without --force. Module and aggregate names must be StudlyCase letters and digits (^[A-Z][A-Za-z0-9]*$).

Where things live:

Path What
app/Modules/ The module contract: base provider, registries (NavRegistry, PermissionRegistry, WidgetRegistry, SearchRegistry), IndexQuery, ModuleSwitch, shared helpers (AuditLog, Toast, TextSearch, RoleOptions, NotificationMessage), shared DTOs in Data/, request and controller traits in Concerns/ (ValidatesBulkSelection, DeletesSelection, ReadsValidatedInput)
app/Domain/ Shared DDD kernel: contracts, InteractsWithDomainEvents, DomainException, the shared value objects Email and Money
app/ (rest) The shell: auth (Fortify), account settings, notifications, search endpoint, shared settings, Rules/AccountRules, Rules/ValidEmail, Actions/DeleteEach
app-modules/<Name>/ One module: code, pages, translations and tests together
resources/js/ Shell frontend: layouts/, components/data-table, components/ui (vendored shadcn), hooks/, lib/
stubs/ddd/ Generator templates (make:module, make:aggregate)
tests/ Shell tests, architecture tests (Unit/ArchTest.php, Unit/Architecture/ModuleBoundariesTest.php), fixtures

Modules: Users, Roles, Audit, Dashboard, Settings and System are core; keep them. Catalog, Shop and Api are examples you can remove.

Building a module

php artisan make:module Billing                # module folder, provider, routes/admin.php, a feature test; registers the provider
php artisan make:aggregate Billing Invoice     # the full CRUD slice below, one aggregate per module
php artisan migrate && php artisan admin:sync-permissions
php artisan typescript:transform && php artisan wayfinder:generate --with-form
composer lint                                  # Rector, Pint, Oxfmt/OxLint: formats the generated code

What make:aggregate writes:

  • model, migration, factory, repository interface and Eloquent implementation;
  • Create/Update/Delete actions (update and delete lock the row first);
  • store/update requests sharing a Http/Requests/Concerns/Validates<Aggregate>Input trait (rules and typed getters), policy, <Plural>IndexQuery, <Aggregate>Data, resource controller;
  • routes/admin.php (overwritten);
  • index/create/edit pages, <aggregate>-columns.tsx, <aggregate>-form-fields.tsx (kebab-case, e.g. invoice-columns.tsx);
  • controller, actions, policy and query tests;
  • new strings in the module's lang/en.json, copied to every other locale in English;
  • provider wiring: repository binding, nav item (icon box, group = module name) and the four <slug>.{view,create,update,delete} permissions for admin.

It refuses a second aggregate in the same module. Add further aggregates by hand, or make a new module. The generated name/description fields are placeholders: replace them with the real fields. Bulk delete, extra abilities (publish) and domain behaviour are added by hand, following Catalog.

Module anatomy (namespace Modules\<Name>\…, PSR-4 to app-modules/<Name>/):

Path Holds
Providers/<Name>ServiceProvider.php Required, exactly here: the base provider finds the module root two levels up
routes/admin.php Required (may be empty). routes/web.php and routes/api.php are optional
Actions/ final readonly invokable classes (one __invoke()): every write
Http/Controllers/, Http/Controllers/Api/V1/ Web and API controllers
Http/Requests/, Http/Requests/Concerns/ FormRequests; shared rules and typed getters in a trait (ValidatesProductInput)
Http/Resources/V1/ API resources
Queries/<Plural>IndexQuery.php Index queries (extend App\Modules\IndexQuery)
Data/ spatie/laravel-data DTOs, scalars only
Policies/<Model>Policy.php Found by policy discovery. Models outside the module: Gate::policy() in register()
Domain/{ValueObjects,Events,Exceptions,Repositories,Enums} The domain layer (see DDD below)
Infrastructure/{Models,Repositories} Eloquent models (aggregates) and Eloquent* repositories
Database/{Migrations,Factories} Capital D. Migrations are up-only. A model overrides newFactory() because its factory lives in the module
Listeners/, Notifications/ Side effects of events; listeners are auto-discovered
Search/<Name>Search.php, Widgets/<Name>Widget.php Invokable, final readonly
Settings/<Name>Settings.php spatie settings; its SettingsMigration goes in Database/Migrations (not database/settings)
Console/Commands/ Registered with $this->commands([...]) in register()
lang/<locale>.json The module's strings
resources/js/{pages,pages/public,components,widgets} Frontend
tests/{Feature,Unit,Browser} The module's tests

Routes are wired by the base provider's final boot():

File URL prefix Route-name prefix Middleware added for you
routes/admin.php /admin admin. web, auth, verified, can:access-panel, module switch
routes/web.php none none web, module switch. Add auth/verified/guest/signed yourself
routes/api.php /api/v1 api.v1. api, auth:sanctum, module switch; throttled to 60/min per user or IP
  • Module switch: a module that is disabled (MODULES_DISABLED=billing, or its Pennant flag module:billing off) answers 404 on every route.
  • Non-CRUD actions get their own route and an invokable controller: Route::post('products/{product}/publish', PublishProductController::class)->name('products.publish').
  • Bulk delete route: Route::delete('products', DeleteProductsController::class)->name('products.bulk-destroy').
  • Precognition (live validation): ->middlewareFor(['store', 'update'], HandlePrecognitiveRequests::class) on the resource route.
  • No module routes in the shell. They never go in routes/web.php; ArchTest enforces it.

Pages. Inertia names are <lowercased module folder>::<path under resources/js/pages>, e.g. catalog::index or users::public/accept-invitation. That is the folder name, not name() (UserPrefs → userprefs::).

Kind Route file Nav area Layout
Admin page admin.php Area::Admin (default) AdminLayout, automatic
Member page (signed-in users without panel access) web.php with auth, verified Area::Member AdminLayout, automatic
Account-settings tab web.php, settings/<x> with auth Area::Settings (no permission) the page wraps itself in <SettingsPage title href> (@/layouts/settings/page); see Api/resources/js/pages/tokens.tsx
Public page (no account) web.php with guest/signed none under pages/public/; no automatic layout, so it wraps itself in AuthLayout

After sign-in, /dashboard redirects to the first nav item the user can see in their area, ordered by group (ungrouped first), then sort, then label.

Request flow (every feature): route → controller → FormRequest → Action → Data DTO → Inertia page.

  • Controllers are final readonly, use only the seven CRUD verbs, and stay a few lines long. Anything else is an invokable single-action controller.
    • Authorize with Illuminate\Routing\Attributes\Controllers\Authorize: #[Authorize('viewAny', Product::class)] for class abilities, #[Authorize('update', 'product')] for instance abilities (the second argument is the route parameter name).
    • Gate::allows() for a page's can prop is fine. Gate::authorize() in a method body is not.
  • FormRequests hold every rule.
    • authorize() returns $this->user()?->can('create', Product::class) ?? false.
    • They expose typed getters ($request->name(), $request->priceCents()) built on ReadsValidatedInput; controllers never read $request->input(). Getters both requests share live in one Http/Requests/Concerns trait: Validates<X>Input when the rules are shared too (the generator writes one), a getters-only trait such as ProvidesRoleInput when they differ.
    • Account fields use App\Rules\AccountRules (name(), email(), password(), roles()); any other email field spreads ...ValidEmail::rules() (they require lowercase, so normalise with Email::normalize() first).
  • Actions own every write: a transaction, AuditLog::record(), events. Compose them rather than duplicating them. Each one has a unit test.
  • Index pages:
    • The controller sends XData::collect($query->paginate(), PaginatedDataCollection::class).
    • IndexQuery implements query() with spatie/query-builder allowedFilters / allowedSorts / defaultSort. A text filter is $this->whereLikeAny($query, ['name', 'sku'], $value).
    • per_page defaults to 15 and is clamped to 1–100. Sorting ties break on the primary key.
    • URL contract: filter[search], filter[<field>] (comma-separated values), sort / -sort, page, per_page.
    • On the page, useResourceTable({ key: '<prop name>', … }) (or useTableState('<prop name>') for a read-only table) reloads only that prop, so the names must match.
  • DTOs: final class XData extends Data with a static fromModel(). Scalars only (no value objects), dates as toIso8601String(), each row with a can map built from Auth::user()?->can(...). Use the generated types globally, without imports (Modules.Catalog.Data.ProductData, App.Enums.Area).
  • Frontend:
    • Forms: useForm(store(), data) with a Wayfinder route inside <FormCard onSubmit={() => form.submit({ preserveScroll: true })}>. The fields live in <XFormFields form={form} />, each a <TextField form={form} name="x" label=…> (value, error and Precognition validate-on-blur come from form + name). FormField, SelectField and CheckboxField are for other controls.
    • Routes come from Wayfinder only: @/routes/admin/products (named) or @/actions/Modules/... (controllers). No hard-coded URLs.
    • Strings: const { t, tChoice } = useLaravelReactI18n(). Dates and money: useFormatters(). Breadcrumbs: useBreadcrumbs({ title, href }, …).
    • Deferred props need a skeleton.
  • Toasts: Toast::success(__('…')) (also info, warning, error; App\Modules\Toast). No Inertia::flash() by hand, no ->with() (only the shell's auth pages keep Fortify's status flash).
  • Activity log: inside the action's transaction, AuditLog::record('<module name()>', '<slug>', $model, ['attributes' => [...]]) (App\Modules\AuditLog; add 'old' => [...] on updates). Give the slug a label in the module's lang/*.json, since the Audit page translates it.
  • Models: UUID keys (HasUuids, $table->uuid('id')->primary(), string $id in DTOs). Text search goes through TextSearch::whereLikeAny() (case-insensitive on SQLite and PostgreSQL), never raw like.

The module provider extends App\Modules\ModuleServiceProvider and implements only the hooks it needs. boot() is final. register() is yours: repository bindings, Gate::policy(), $this->commands().

final class BillingServiceProvider extends ModuleServiceProvider
{
    public function register(): void
    {
        $this->app->bind(InvoiceRepository::class, EloquentInvoiceRepository::class);
    }

    protected function name(): string { return 'billing'; }   // kebab-case: Pennant flag, MODULES_DISABLED, activity log name

    protected function navigation(NavRegistry $nav): void
    {
        // add(module, label, routeName, ?icon, ?permission, ?group, sort = 0, external = false, area = Area::Admin)
        $nav->add(module: $this->name(), label: 'Invoices', routeName: 'admin.invoices.index',
            icon: 'receipt', permission: 'invoices.view', group: 'Billing', sort: 10);
    }

    protected function permissions(PermissionRegistry $permissions): void
    {
        $permissions->declare('invoices.view', roles: ['admin']);
    }

    protected function widgets(WidgetRegistry $widgets): void
    {
        // declare(module, key, resolver, ?permission, sort = 0, area = Area::Admin)
        // The resolver is an invokable `__invoke(User $user): ?Data` (null hides the card); the
        // card's component is app-modules/Billing/resources/js/widgets/billing.unpaid.tsx.
        $widgets->declare(module: $this->name(), key: 'billing.unpaid', resolver: UnpaidInvoicesWidget::class, permission: 'invoices.view');
    }

    protected function search(SearchRegistry $search): void
    {
        // add(module, label, searcher, ?icon, ?permission, sort = 0)
        // The searcher is an invokable `__invoke(string $term, User $user, int $limit): list<SearchResultData>`.
        $search->add(module: $this->name(), label: 'Invoices', searcher: InvoicesSearch::class, icon: 'receipt', permission: 'invoices.view');
    }
}
  • Labels and groups (label:, group:) are English translation keys: add each one to the module's lang/*.json. TranslationKeysTest checks them.
  • Icons are kebab-case lucide names that must exist in navIconMap (resources/js/components/admin/nav.ts). Unknown names render a question mark. Adding an icon is a shell change.
  • Widget components default-export ({ data }: { data: Modules.Billing.Data.UnpaidData }). The file name must equal the widget key; an unknown key renders nothing.

Boundaries.

  • Modules never import each other, and the shell never imports a module (PHP: ModuleBoundariesTest). Frontend code follows the same rule by convention.
  • Modules talk through events. An event another module may handle is an integration event in app/Domain/Events (create the folder with the first one).
  • php artisan module:remove <Name> --force must leave a green suite.

Domain-Driven Design (for anything with rules)

Each module is a bounded context. Whatever has business rules (invariants, a lifecycle, one-time steps, values that must be valid) goes through the domain layer. Plain CRUD, settings and token screens don't need it. Full guide: docs/specs/ddd.md.

Piece Where Example
Aggregate: our Eloquent model, with behaviour that guards its rules Infrastructure/Models Product::draft(), ->revise(), ->publish(); Order::place(), ->transitionTo(); Invitation::issue(), ->accept(), ->renew(), ->revoke()
Value object: final readonly, implements App\Domain\Contracts\ValueObject, validates itself Domain/ValueObjects, or app/Domain/ValueObjects when shared Sku, OrderNumber, Email, RoleName, Money
Domain exception: a broken rule; extends App\Domain\Exceptions\DomainException, named constructors Domain/Exceptions ProductAlreadyPublished::withId(), SkuAlreadyTaken, InvalidOrderTransition
Domain event: recorded by the aggregate, scalars only Domain/Events ProductPublished, OrderStatusChanged
Repository: interface plus Eloquent* implementation, bound in register(). Methods: lockForUpdate(), save(), delete(). No finders: reads go through queries Domain/Repositories, Infrastructure/Repositories ProductRepository, InvitationRepository
Listener: side effects (emails, notifications) Listeners/ NotifyCustomerOfOrderStatus, SendInvitationEmail

The flow inside an action:

  1. Inside DB::transaction, call $repository->lockForUpdate($aggregate) (or create the aggregate).
  2. Call its behaviour; it throws a domain exception when a rule is broken.
  3. Save through the repository.
  4. After the transaction returns, call $aggregate->flushDomainEvents(). Laravel's own events and mails sent from inside an action go through DB::afterCommit().

Models we don't own (Spatie Role, the shell's User) use the lightweight shape: the rule lives in a value object (RoleName::isSystem()), the action enforces it, and policies, requests and DTOs ask the same object.

Rules about who acts ("grant only roles you hold") are authorization: they stay in policies and requests.

Domain exceptions at the edge are never reported (bootstrap/app.php). An Inertia request gets a generic error toast plus back(); the message itself is not shown. A JSON request without X-Inertia gets 409 {"message": …}. Policies should stop the request first; the exception is the backstop for races and stale pages.

Permissions

All authorization goes through Laravel's Gate. Spatie Permission only stores roles and assignments.

  1. Declare permissions in the provider's permissions().

    • Names match ^[a-z0-9-]+(\.[a-z0-9-]+)+$, e.g. invoices.view. A bare name like update throws at boot, because it would shadow policy methods.
    • Disabled modules still declare theirs.
  2. Sync with php artisan admin:sync-permissions (the production entrypoint runs it after migrating). It is idempotent:

    • creates declared permissions and missing roles;
    • grants each permission to its declared roles;
    • deletes permissions no module declares.

    It never revokes a grant an admin added by hand.

  3. Check permissions, never roles:

    • policies: return $user->can('invoices.update') && $invoice->isDraft();
    • controllers: #[Authorize(...)]
    • routes: can: middleware
    • requests: authorize()
    • anywhere else: $user->can()
  4. Ship decisions to the UI as can maps in DTOs. The frontend never decides access itself.

How access is decided:

  • Panel entry is the gate access-panel (User::PANEL_ABILITY). It calls User::canAccessPanel(), which checks the admin role. Change who enters the admin area there.
    • A user with only super-admin and no admin role does not enter.
    • Everyone else lands on the member home.
  • Super-admin passes every declared permission (Gate::before), but policies' state rules still bind ("paid orders are kept").
  • Granting is capped. Admins grant only roles and permissions they hold themselves, so an admin can make another admin but never a super-admin. Only a super-admin edits super-admins or the admin/super-admin roles; those roles can never be renamed or deleted.
  • Impersonation: needs users.impersonate. Nobody can impersonate a user who holds users.impersonate (admins, super-admins).
  • API tokens carry a subset of the user's permissions, chosen at creation. A permission the token lacks is denied, even for a super-admin. Clients send Authorization: Bearer <token> and Accept: application/json.

Shell building blocks

Need Use Example to copy
Menu item, member page, account tab navigation() with Area::Admin / Member / Settings (see "Pages" above) Catalog, Dashboard, Api providers
Dashboard card widgets() + Widgets/<Name>Widget.php + resources/js/widgets/<key>.tsx in the module Users widgets
Command-palette search search() + Search/<Name>Search.php Shop/Search/OrdersSearch
Table with bulk delete Server: a request using ValidatesBulkSelection (ids 1–100) with a getter returning $this->permitted(Model::query(), 'delete'); an invokable controller using DeletesSelection that calls $this->deleteSelection($request, $request->products(), $deleteProduct(...), fn (int $count) => trans_choice(...)), then redirects. Client: useResourceTable({ key, rows, destroyUrl, bulkDestroyUrl }) + <DataTableBulkDelete> Catalog DeleteProductsController, pages/index.tsx
Delete with confirmation Index page: useResourceTable's deleteDialog + <DataTableDeleteDialog>. Other lists: useDeleteDialog + ConfirmDialog. Any other confirmed visit: useConfirmDialog + ConfirmDialog Catalog/resources/js/pages/index.tsx, Api tokens page, Shop's cancel-order
Notify a user (bell + email) extend AppNotification (queued: needs the queue worker), implement message(User): NotificationMessage, send from a listener. Email only when the user has notify_by_email Shop/Listeners/NotifyCustomerOfOrderStatus
Admin-editable settings spatie settings class in Settings/, its SettingsMigration in Database/Migrations, routes GET|PUT admin/settings/<x>, nav group Settings Shop/Settings/ShopSettings
File uploads HasMedia on the model, ImageInput component, File::image() rule, a POST route (PUT cannot carry files). Files go to MEDIA_DISK (public or s3) User avatar
API endpoints routes/api.php, controllers in Http/Controllers/Api/V1, resources in Http/Resources/V1 Shop orders API
A shared shape for the frontend DTO in app/Modules/Data SearchResultData, SessionData

Shared helpers: reuse them, never re-implement them. Each one is the only version in the codebase.

PHP What it does
App\Modules\Toast::success|info|warning|error(string) Flash the toast the frontend shows after the redirect
App\Modules\AuditLog::record(log, event, ?subject, properties, ?causer, ?description) Write one activity-log entry (the description defaults to the event slug)
App\Modules\Concerns\DeletesSelection → deleteSelection($request, $records, $deleteOne, fn (int $count) => trans_choice(...)) A bulk-delete controller's body except the redirect: 403 when nothing is permitted, DeleteEach, the "N deleted / M could not be deleted" toast
App\Actions\DeleteEach(iterable $records, Closure $delete) Delete a selection through the single-row action, all or nothing
App\Modules\Concerns\ValidatesBulkSelection Bulk-selection request: ids rules (1–100) and permitted(Model::query(), 'delete')
App\Modules\Concerns\ReadsValidatedInput Request trait: validatedString(), validatedNullableString(), normalizeInput() (before validation)
App\Rules\AccountRules::name|email|password|roles(), ValidEmail::rules(), Money::currencyRules() The rule sets for account fields, any email field and any currency code
App\Modules\RoleOptions::roleNames|permissionNames() Sorted role and permission names for forms
User::PANEL_ROLE, User::SUPER_ADMIN_ROLE The role names; never write 'admin' / 'super-admin'
App\Modules\Format::money(int $minorUnits, ?string $currency), ::percent(float $ratio), ::distance(float $km) Figures in the business's regional format, currency and distance unit (Settings → Business), for emails, notifications and exports
App\Modules\BusinessTime::toUtc(), ::fromUtc(), ::now(), ::dayInUtc() Between the business clock and UTC (the only zone stored): read typed business times, show stored instants, query a business day (whereBetween('created_at', BusinessTime::dayInUtc()))
App\Modules\IndexQuery::whereLikeAny() / App\Modules\TextSearch::whereLikeAny() Case-insensitive "any of these columns contains the term" (index filters, command-palette searchers)
App\Modules\Data\SearchResultData::forRecord() A command-palette result linking to the edit page, or to the filtered list without update
App\Domain\ValueObjects\Email::normalize(), Money::normalizeCurrency() The one place an email or a currency code is trimmed and cased
User::changeEmail(string) Change an address: clears verification and sends the verification mail after commit
Frontend (@/…) What it does
components/text-field → TextField A form-bound field (text, email, password, url, tel, number, currency via CurrencyInput, date-time, multi-line): id, value, change, error and Precognition validate-on-blur from form + name
components/form-field → FormField; select-field → SelectField; checkbox-field → CheckboxField Label, control, hint and error for a custom control; a labelled select over {value, label} options; a labelled checkbox
components/checkbox-group → CheckboxGroupField, CheckboxList A fieldset of checkboxes over a string array (lib/selection → toggleValue, toggleValues)
components/form-card → FormCard; section-card → SectionCard, PageStack The single-card form (title, fields, submit with spinner, optional cancel); a titled non-form card; the max-w-2xl page column
types/ui → FormLike<T>, FormFieldsProps<T> A module's <X>FormFields takes form={form} (the useForm result)
hooks/use-resource-table → useResourceTable({ key, rows, destroyUrl, bulkDestroyUrl? }) → { tableState, deleteDialog, selection, bulkDelete } An index page's table state, row selection, delete dialog and bulk delete in one call (wraps useTableState, useRowSelection, useDeleteDialog, useBulkDelete)
components/data-table → sortableColumn, dateColumn, actionsColumn, EditMenuItem, DeleteMenuItem, EmptyValue Column building blocks for every *-columns.tsx
components/data-table → DataTableCreateButton, DataTableBulkDelete, DataTableDeleteDialog Index-page toolbar button, bulk-delete bar with its dialog, single-delete dialog
hooks/use-confirm-dialog → useConfirmDialog (wrapped by useDeleteDialog, useBulkDelete) Open, confirm and track a visit that needs confirmation
components/password-confirm-dialog → PasswordConfirmDialog A dialog that asks for the password before a sensitive <Form> submits
layouts/settings/page → SettingsPage; components/settings-section → SettingsSection An account-settings page (title once: head, heading, breadcrumb, tabs); a titled section inside it
components/auth/auth-submit → AuthSubmit The full-width submit button of the sign-in pages
components/image-input → ImageInput Image preview with upload (multipart POST) and remove, given field, uploadUrl, removeUrl
components/copy-field → CopyField, CopyButton A read-only value with a copy button (clipboard, icon swap, error toast)
components/badge-list → BadgeList; user-info → UserInfo Badges with "+N more"; avatar with name and email
components/empty-state → EmptyState; widget-card → WidgetCard; item-list → ItemList/ItemRow Empty messages (optional icon); dashboard widget cards; bordered record lists with " · "-joined details
components/mark-all-read-button → MarkAllReadButton Marks every notification read (bell and notifications page)
components/combobox-field → ComboboxField A labelled, searchable dropdown for long lists (time zones, currencies)
hooks/use-consent → useConsent() (allows(category)) Whether the visitor allows an optional cookie category; load analytics or ads only behind it
lib/utils → handleSubmit(submit); hooks/use-breadcrumbs → useBreadcrumbs(...items) onSubmit that prevents the default; the page's breadcrumb trail, inline
Tests (tests/Pest.php) What it does
adminUser(), superAdminUser(), adminWithout($permissions) An admin; a super-admin; an admin whose role lacks the given permission(s)
assertAdminOnly($method, $url) Guests go to sign-in, signed-in non-admins get 403 (every admin controller's first test)
->assertToast($message, $type = 'success') The toast flashed for the next page
indexQuery(Class::class, $query), assertPageAbilityFollowsPermission($route, $ability, $permission) An index query from request parameters; a page can.* prop that follows a permission
invalidBulkSelections(), validImage() Bulk-delete validation dataset; a valid fake upload

Shared Inertia props (typed in resources/js/types/global.d.ts):

  • the user: auth.user, impersonation;
  • localisation: locale, locales, timezone, business (timezone, regional format, currency, distance unit);
  • privacy: consent (read it through useConsent());
  • navigation: nav, settingsNav;
  • theming: layout, theme, appearance;
  • branding: logo, supportEmail;
  • announcement, unreadNotifications.

Content Security Policy (app/Http/Middleware/SecurityHeaders.php):

  • Scripts are limited to self plus a nonce; an inline <script> needs nonce="{{ Vite::cspNonce() }}".
  • connect-src 'self': the browser calls no third-party API.
  • Images are limited to self, data: and the MEDIA_DISK origin.
  • Fonts and styles come from self and fonts.bunny.net only.
  • No framing and no third-party scripts.

A new external origin means editing policy(), which is a shell change.

Already in the shell:

  • Accounts: Fortify auth with 2FA and passkeys, invitations, impersonation, sessions, avatars, timezone.
  • Communication: notifications (bell and email), branded emails, an announcement banner.
  • Admin tools: the audit log, plus Pulse, Horizon and Log Viewer (System module).
  • Platform: theming (below), plus security headers and CSP.
  • Privacy: cookie consent, data export and erasure, legal pages, terms acceptance, retention (see "Privacy").

Privacy (GDPR, CCPA/CPRA and the US state laws)

Everything is set in config/privacy.php. The legal texts are Markdown files: resources/legal/<locale>/{terms,privacy,cookies}.md. They are templates: fill in the bracketed parts and have a lawyer review them before going live. :app becomes the app name. :contact becomes the support email (Settings → General), or the mail-from address when none is set. A language without a file falls back to app.fallback_locale.

Piece Where What it does
Legal pages GET /legal/{terms,privacy,cookies} (LegalDocumentController) Public, in the visitor's language. Rendered on the server with raw HTML stripped. The Cookie Policy page ends with the cookie choices form
Cookie consent ConsentBanner (in every layout), POST /privacy/consent, ResolveConsent, the consent shared prop Opt-in: nothing optional runs before the visitor chooses. "Reject all" is as easy as "Accept all". Each choice is stored as a ConsentRecord (proof of consent) plus the encrypted consent cookie. The banner returns when privacy.consent.version changes or after lifetime_days
US opt-out Sec-GPC: 1, ConsentCategory::isSaleOrSharing(), the "Your privacy choices" link (LegalLinks) A Global Privacy Control signal switches off the "sale or sharing" categories (marketing), whatever the visitor chose
Terms acceptance privacy.terms_version, users.terms_version/terms_accepted_at, EnsureTermsAccepted, /terms/accept Sign-up requires it. When the version changes, every page visit goes to the acceptance page first. Invited and admin-created users accept on their first visit. The privacy rights, the legal pages and log out stay open; an admin impersonating someone is never asked
Data export Settings → Privacy, GET /settings/privacy/export (password confirmed), ExportPersonalData One JSON file: the account, sign-in methods, sessions, notifications, cookie choices, audit entries and every module's data. Secrets are left out. The request is audit-logged
Erasure ErasePersonalData, called by DeleteAccount and the Users module's DeleteUser Runs every module's eraser, then deletes tokens, notifications and sessions. Consent records stay, unlinked
Retention privacy:prune (scheduled daily), privacy.retention Deletes read notifications, old consent records and sign-ups never verified and never given a role. The audit log keeps its own activitylog:clean

A module that stores personal data declares it in its provider, so exports and erasure include it:

protected function personalData(PersonalDataRegistry $personalData): void
{
    // exporter: invokable `__invoke(User $user): array`; eraser (optional): `__invoke(User $user): void`
    $personalData->add(module: $this->name(), key: 'billing.invoices', exporter: InvoicesPersonalData::class, eraser: EraseInvoices::class);
}
  • Sources live in PersonalData/, as final readonly invokable classes (ArchTest checks this).
  • Leave out the eraser for records the law obliges you to keep, and say so in the privacy policy. Shop's OrdersPersonalData is the example.
  • Sources run even while their module is switched off.

Optional scripts (analytics, ads) load only with consent:

  • On the client: const { allows } = useConsent(); if (allows('analytics')) ….
  • On the server: resolve(ResolveConsent::class)($request)->allows(ConsentCategory::Analytics).
  • A new purpose needs a case in App\Enums\ConsentCategory (label, description, isSaleOrSharing()) and its value in privacy.consent.categories. Then bump privacy.consent.version and describe it in cookies.md.
  • The third-party origin also has to be added to the CSP (SecurityHeaders::policy()).
  • With no category listed, no banner shows.

Styling and layout

Every colour, font, radius and size is a CSS variable (a token), and one file decides the shell's structure. Restyle by changing token values; relayout by changing the layout config. Never edit pages or module components to change the look.

bun run lint:theme fails on:

  • raw colours: bg-white, #fff, oklch(…) in a class or style;
  • physical direction classes such as ml-, pr-, left-. Use ms-, pe-, start-.

Full contract: THEMING.md.

To change Edit Notes
Colours the :root (light) and .dark blocks in resources/css/app.css Shadcn names: --background, --primary, --muted, --sidebar-*, --chart-1…5. Keep WCAG AA on text/background and primary-foreground/primary
Page background --background, and the matching background-color in resources/views/app.blade.php That inline style paints before the CSS loads; FirstPaintBackgroundTest fails when they differ
Fonts --font-body, --font-display, --font-mono in app.css, and the font <link> in app.blade.php font-sans follows --font-body
Corners, row spacing, sizes --radius, --density (table rows), --header-height, --content-max-width (boxed width), --sidebar-width, --sidebar-width-icon, --sidebar-width-mobile Presets may override these too
One component's look resources/js/components/ui/* (vendored shadcn) A shell change: every module gets it. Use tokens, never colours
Logo, favicons, auth artwork, error pages, emails see "Branding a project" in THEMING.md rg -n "@branding" lists every spot

Adding a theme preset (users pick it in the header menu or the command palette):

  1. Copy resources/css/themes/ember.css to themes/<name>.css. Keep only the overridden tokens, in a :root[data-theme='<name>'] block and a .dark companion block.
  2. @import it in resources/css/app.css.
  3. Add a case to App\Enums\ThemePreset, then run php artisan typescript:transform.
  4. Add the option to themePresetOptions in resources/js/components/admin/theme-options.ts, and its label to lang/*.json.
  5. If it changes --background, add an html[data-theme='<name>'] (or html.dark[data-theme='<name>']) rule to the first-paint style in app.blade.php.

Layout. Each user chooses these options from the topbar settings menu or the command palette:

Option Values Default
nav_placement sidebar-left · sidebar-right · topbar sidebar-left
sidebar_variant sidebar · floating · inset sidebar
sidebar_collapsible offcanvas · icon · none icon
content_width fluid · boxed fluid
header sticky · static sticky
direction ltr · rtl ltr
  • Defaults for everyone: ResolveUserPreferences::DEFAULT_LAYOUT. The default preset and appearance are the fallbacks in its __invoke(). A user's own choice still wins.

  • Changing how a variant renders: resources/js/layouts/admin-layout.tsx is the only file that reads the layout config. It assembles components/admin/admin-sidebar.tsx, admin-topbar.tsx and admin-header.tsx. Menu items come from module navigation() hooks, never from the layout.

  • Adding an option or value:

    1. Add the case to its enum in app/Enums.
    2. For a new option, add it to LayoutConfigData, ResolveUserPreferences::LAYOUT_OPTIONS (resolution and validation read it) and DEFAULT_LAYOUT.
    3. Run php artisan typescript:transform.
    4. Add the branch in admin-layout.tsx.
    5. Add the menu entry in theme-options.ts and its translations.

    Free-form or drag-and-drop layouts are out of scope by design.

  • Other layouts:

    • Account settings pages sit inside AdminLayout with layouts/settings/layout.tsx; its tab list adds the module-registered tabs.
    • Sign-in pages and public module pages use layouts/auth-layout.tsx.
    • They all use the same tokens, so restyling reaches them too.
  • Module pages render into the content area. They never read layout config, position themselves against the shell, or add global CSS.

After a change, check light and dark, each preset, rtl, and the topbar layout (tests/Browser/ThemeMatrixTest.php covers the combinations), then run the quality gates.

Translations

  • Keys: flat JSON with English keys, used by __() in PHP and t() / tChoice() in React.

    • Shell strings live in lang/<locale>.json; a module's live in app-modules/<Name>/lang/<locale>.json.
    • A module never relies on another module's strings; shared words belong in the shell.
    • On the client, catalogues merge per locale and the shell wins, so a project can reword a module in lang/<locale>.json.
  • Checked by TranslationKeysTest:

    • every literal t(), tChoice(), __(), trans() and trans_choice() string;
    • every provider label: and group:;
    • missing keys, empty lines and dropped :placeholders, across all locales.

    A key passed as a variable (t(orderStatuses[status].label)) is not checked: add those strings to the catalogue yourself.

  • Languages: English and French ship. To add a locale:

    1. Copy every en.json.
    2. Add lang/<code>/*.php; start from the French ones.
    3. Register the locale in config/app.php under available_locales.
    4. Translate the legal documents into resources/legal/<code>/ (until then they show in the fallback language).
  • Formatting: everything goes through useFormatters() (Format on the server).

    • Dates follow the user's language and timezone.
    • Figures follow the business (admin Settings → Business), so they read the same for everyone: money(minorUnits, currency?) (the business currency by default), percent(ratio) (0.125 → "12.5%"), distance(km) (in the business unit) and number(value).
    • Times on the business clock: businessToUtc() / utcToBusiness() in @/lib/format, BusinessTime in PHP.

Testing and quality gates (all must pass)

composer test          # CI's main job: lint check, type coverage, PHPStan + tsc, tests with 100% coverage
composer lint          # fixers: Rector, Pint, Oxfmt, OxLint, theme lint

The same gates one by one:

vendor/bin/pint --dirty --format agent && vendor/bin/rector && vendor/bin/phpstan
bun run test:types && bun run test:lint
bun run build                                                                # browser tests use the production build
XDEBUG_MODE=coverage vendor/bin/pest --parallel --coverage --exactly=100.0   # includes browser tests
vendor/bin/pest --type-coverage --min=100
php artisan test --compact app-modules/Catalog/tests/Feature/ProductControllerTest.php   # one file
  • Global test setup (tests/Pest.php, applied to shell and module tests alike):
    • RefreshDatabase on in-memory SQLite;
    • frozen time, no stray HTTP requests or processes, fake Sleep;
    • QUEUE_CONNECTION=sync, PENNANT_STORE=array;
    • withoutVite() for every Feature test (not Browser tests);
    • the helpers in the "Tests" table above.
  • Permissions in tests: feature tests start with beforeEach(fn () => $this->artisan('admin:sync-permissions')->assertSuccessful());. Without the sync, admins have no permissions.
  • Coverage: every controller gets an assertAdminOnly() test, a forbidden test (adminWithout()), validation and happy-path tests (assertToast()). Every action, policy and query gets a unit test.
  • Browser tests: they fail while public/hot exists, so stop composer run dev and run bun run build first. They need bunx playwright install once.
  • CI (.github/workflows/tests.yml, each job set up by .github/actions/setup): bun run build + composer test; the Feature and Unit suites on PostgreSQL 18; a generators job that scaffolds a module and an aggregate, then runs lint, types and the generated tests; and a production-image job that builds and boots the production stack and checks /up.
  • Portable queries: CI runs on SQLite and PostgreSQL, so use whereLike, no raw SQL dialects.
  • Never run migrate:fresh, db:wipe or --env=testing commands: there is no .env.testing, and they would wipe the dev database. Only additive php artisan migrate.

Rules that fail review

  • Writes outside actions; controller methods beyond the CRUD verbs; Gate::authorize() in method bodies; role checks.
  • Hand-written TypeScript types for server data; hard-coded URLs; client-side data stores; npm.
  • Raw colours, palette classes or physical direction utilities in frontend code.
  • A module importing another module, or the shell importing a module.
  • Edited generated files (see the table above).
  • New dependencies outside the approved list in docs/specs/build-prompt.md.
  • Commit messages not in conventional form (feat:, fix:, docs:, chore(deps):, refactor:, test:).

Using Atrium as a template

  1. Create a project from this repo, set up .env, then follow the quick start.
  2. git remote add atrium [email protected]:DevPlus31/atrium.git. Pull improvements with git fetch atrium && git merge atrium/main.
  3. Build only modules. Leave the shell untouched (app/Modules, app/Domain, layouts, data table, theming, core modules) so merges stay clean. Make shell changes upstream first.
  4. Drop the examples you don't need:
    • Run php artisan module:remove Shop --force (also Catalog, Api).
    • Then run admin:sync-permissions, typescript:transform, wayfinder:generate --with-form and bun run build.
    • The module's tables are kept and listed; drop them with a migration when you want the data gone.

Multi-tenancy is deliberately left to each project. Atrium is single-tenant. Decide on tenancy before the first module.

Deploying

docker-compose.prod.yml runs app, horizon, pulse, scheduler, postgres (18) and redis on one host. CI's production-image job builds and boots this exact stack on every push.

  1. Environment: cp .env.production.example .env.production, then fill in every value its comments mark as required:
    • APP_KEY, APP_URL, and DB_PASSWORD (the same value as POSTGRES_PASSWORD).
    • PASSKEYS_USER_HANDLE_SECRET: set it once, before anyone registers a passkey, and never change it.
    • The container refuses to start without APP_KEY or PASSKEYS_USER_HANDLE_SECRET.
  2. Mail: set a real MAIL_MAILER (smtp, ses, postmark, resend).
    • With log, verification, password-reset and invitation emails are never sent, so new users cannot get in.
    • The entrypoint warns about this on every start.
  3. HTTPS: run a reverse proxy on the host (Caddy, nginx, Traefik) that terminates TLS and forwards to 127.0.0.1:8080.
    • The app port is bound to localhost (APP_BIND), and TRUSTED_PROXIES trusts only loopback and private ranges.
    • A load balancer on another machine needs APP_BIND=0.0.0.0, its IPs in TRUSTED_PROXIES, and a firewall.
    • Never set TRUSTED_PROXIES=* on a reachable port: clients could fake their IP and get around the rate limits.
    • Session cookies are secure-only, so sign-in works only over HTTPS.
  4. Start: docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build.
  5. First admin: docker compose --env-file .env.production -f docker-compose.prod.yml exec app php artisan admin:create-user.
  6. Before going live:
    • Decide on public sign-up (admin Settings → General, open by default).
    • Set the business timezone, regional format, currency and distance unit (admin Settings → Business).
    • Fill in and have a lawyer review resources/legal/*/*.md, and set config/privacy.php (consent categories, terms version, retention).
    • Remove the example modules (see "Using Atrium as a template").
    • Decide on tenancy.
  7. Backups: nothing is backed up for you.
    • Schedule a database dump, e.g. docker compose … exec -T postgres pg_dump -U atrium atrium | gzip > atrium-$(date +%F).sql.gz, and copy it off the host.
    • Back up the storage-app volume (uploads), or use MEDIA_DISK=s3.

What the entrypoint does on every start:

  1. Checks the required secrets.
  2. Waits for the database.
  3. Runs migrate --force --isolated (with several app replicas, one migrates) and admin:sync-permissions.
  4. Rebuilds the caches (config, route, view, event).

WAIT_FOR_DB, RUN_MIGRATIONS and RUN_OPTIMIZE switch the steps off. New listeners need the event cache rebuilt, which a redeploy does.

Health: GET /up answers 200 only when PHP, the database and the cache respond (a DiagnosingHealth listener in AppServiceProvider). The image's HEALTHCHECK calls it, and the worker containers wait for it.

PostgreSQL upgrades: a major version cannot open the previous one's data, so Dependabot skips major bumps. To upgrade (including an existing PostgreSQL 17 install moving to 18, whose volume path also changed to /var/lib/postgresql):

  1. pg_dumpall on the old version.
  2. Stop the stack and remove the postgres-data volume.
  3. Change the image and start the stack.
  4. Restore the dump.

License

MIT. Built on nunomaduro/laravel-starter-kit-inertia-react.

About

Modular Laravel 13 + Inertia v3 + React 19 admin panel template — module contract, roles/permissions, passkeys, theming system, 100% coverage

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages