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.
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 usenpm. - First super-admin: no command creates one. Run
php artisan db:seed --class=RoleSeeder(creates thesuper-adminrole), thenphp artisan tinker --execute '\App\Models\User::where("email", "[email protected]")->first()->assignRole("super-admin");'. - Production: follow the checklist in "Deploying" below.
.env.exampleand.env.production.examplelist and explain every setting.
Sources of truth, in order:
- This README. Wins over everything below when they disagree.
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.CLAUDE.md: generated by Laravel Boost from.ai/guidelines/*.blade.phpplus package guidelines. Edit.ai/guidelines, neverCLAUDE.md. Its generic Laravel advice ("usephp artisan make:", "create seeders") applies to the shell only. Module files come frommake:module/make:aggregateor are written by hand inside the module; modules have factories, not seeders.- Skills in
.claude/skills(laravel-best-practices,wayfinder-development,fortify-development)..github/skillsis the same set for Copilot. - The reference modules.
app-modules/Catalogis canonical: copy its shapes.Shopadds events, listeners, notifications, settings and an API.Usersshows 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.
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 codeWhat 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>Inputtrait (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 foradmin.
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 flagmodule:billingoff) 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;ArchTestenforces 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'scanprop is fine.Gate::authorize()in a method body is not.
- Authorize with
- FormRequests hold every rule.
authorize()returns$this->user()?->can('create', Product::class) ?? false.- They expose typed getters (
$request->name(),$request->priceCents()) built onReadsValidatedInput; controllers never read$request->input(). Getters both requests share live in oneHttp/Requests/Concernstrait:Validates<X>Inputwhen the rules are shared too (the generator writes one), a getters-only trait such asProvidesRoleInputwhen they differ. - Account fields use
App\Rules\AccountRules(name(),email(),password(),roles()); any other email field spreads...ValidEmail::rules()(they require lowercase, so normalise withEmail::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). IndexQueryimplementsquery()with spatie/query-builderallowedFilters/allowedSorts/defaultSort. A text filter is$this->whereLikeAny($query, ['name', 'sku'], $value).per_pagedefaults 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>', … })(oruseTableState('<prop name>')for a read-only table) reloads only that prop, so the names must match.
- The controller sends
- DTOs:
final class XData extends Datawith a staticfromModel(). Scalars only (no value objects), dates astoIso8601String(), each row with acanmap built fromAuth::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 fromform+name).FormField,SelectFieldandCheckboxFieldare 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.
- Forms:
- Toasts:
Toast::success(__('…'))(alsoinfo,warning,error;App\Modules\Toast). NoInertia::flash()by hand, no->with()(only the shell's auth pages keep Fortify'sstatusflash). - 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'slang/*.json, since the Audit page translates it. - Models: UUID keys (
HasUuids,$table->uuid('id')->primary(),string $idin DTOs). Text search goes throughTextSearch::whereLikeAny()(case-insensitive on SQLite and PostgreSQL), never rawlike.
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'slang/*.json.TranslationKeysTestchecks 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> --forcemust leave a green suite.
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:
- Inside
DB::transaction, call$repository->lockForUpdate($aggregate)(or create the aggregate). - Call its behaviour; it throws a domain exception when a rule is broken.
- Save through the repository.
- After the transaction returns, call
$aggregate->flushDomainEvents(). Laravel's own events and mails sent from inside an action go throughDB::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.
All authorization goes through Laravel's Gate. Spatie Permission only stores roles and assignments.
-
Declare permissions in the provider's
permissions().- Names match
^[a-z0-9-]+(\.[a-z0-9-]+)+$, e.g.invoices.view. A bare name likeupdatethrows at boot, because it would shadow policy methods. - Disabled modules still declare theirs.
- Names match
-
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.
-
Check permissions, never roles:
- policies:
return $user->can('invoices.update') && $invoice->isDraft(); - controllers:
#[Authorize(...)] - routes:
can:middleware - requests:
authorize() - anywhere else:
$user->can()
- policies:
-
Ship decisions to the UI as
canmaps in DTOs. The frontend never decides access itself.
How access is decided:
- Panel entry is the gate
access-panel(User::PANEL_ABILITY). It callsUser::canAccessPanel(), which checks theadminrole. Change who enters the admin area there.- A user with only
super-adminand noadminrole does not enter. - Everyone else lands on the member home.
- A user with only
- 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-adminroles; those roles can never be renamed or deleted. - Impersonation: needs
users.impersonate. Nobody can impersonate a user who holdsusers.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>andAccept: application/json.
| 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 throughuseConsent()); - navigation:
nav,settingsNav; - theming:
layout,theme,appearance; - branding:
logo,supportEmail; announcement,unreadNotifications.
Content Security Policy (app/Http/Middleware/SecurityHeaders.php):
- Scripts are limited to
selfplus a nonce; an inline<script>needsnonce="{{ Vite::cspNonce() }}". connect-src 'self': the browser calls no third-party API.- Images are limited to
self,data:and theMEDIA_DISKorigin. - Fonts and styles come from
selfandfonts.bunny.netonly. - 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").
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/, asfinal readonlyinvokable classes (ArchTestchecks this). - Leave out the eraser for records the law obliges you to keep, and say so in the privacy policy.
Shop'sOrdersPersonalDatais 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 inprivacy.consent.categories. Then bumpprivacy.consent.versionand describe it incookies.md. - The third-party origin also has to be added to the CSP (
SecurityHeaders::policy()). - With no category listed, no banner shows.
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-. Usems-,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):
- Copy
resources/css/themes/ember.csstothemes/<name>.css. Keep only the overridden tokens, in a:root[data-theme='<name>']block and a.darkcompanion block. @importit inresources/css/app.css.- Add a
casetoApp\Enums\ThemePreset, then runphp artisan typescript:transform. - Add the option to
themePresetOptionsinresources/js/components/admin/theme-options.ts, and its label tolang/*.json. - If it changes
--background, add anhtml[data-theme='<name>'](orhtml.dark[data-theme='<name>']) rule to the first-paint style inapp.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.tsxis the only file that reads the layout config. It assemblescomponents/admin/admin-sidebar.tsx,admin-topbar.tsxandadmin-header.tsx. Menu items come from modulenavigation()hooks, never from the layout. -
Adding an option or value:
- Add the case to its enum in
app/Enums. - For a new option, add it to
LayoutConfigData,ResolveUserPreferences::LAYOUT_OPTIONS(resolution and validation read it) andDEFAULT_LAYOUT. - Run
php artisan typescript:transform. - Add the branch in
admin-layout.tsx. - Add the menu entry in
theme-options.tsand its translations.
Free-form or drag-and-drop layouts are out of scope by design.
- Add the case to its enum in
-
Other layouts:
- Account settings pages sit inside
AdminLayoutwithlayouts/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.
- Account settings pages sit inside
-
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.
-
Keys: flat JSON with English keys, used by
__()in PHP andt()/tChoice()in React.- Shell strings live in
lang/<locale>.json; a module's live inapp-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.
- Shell strings live in
-
Checked by
TranslationKeysTest:- every literal
t(),tChoice(),__(),trans()andtrans_choice()string; - every provider
label:andgroup:; - 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. - every literal
-
Languages: English and French ship. To add a locale:
- Copy every
en.json. - Add
lang/<code>/*.php; start from the French ones. - Register the locale in
config/app.phpunderavailable_locales. - Translate the legal documents into
resources/legal/<code>/(until then they show in the fallback language).
- Copy every
-
Formatting: everything goes through
useFormatters()(Formaton 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) andnumber(value). - Times on the business clock:
businessToUtc()/utcToBusiness()in@/lib/format,BusinessTimein PHP.
composer test # CI's main job: lint check, type coverage, PHPStan + tsc, tests with 100% coverage
composer lint # fixers: Rector, Pint, Oxfmt, OxLint, theme lintThe 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):RefreshDatabaseon 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/hotexists, so stopcomposer run devand runbun run buildfirst. They needbunx playwright installonce. - 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; ageneratorsjob that scaffolds a module and an aggregate, then runs lint, types and the generated tests; and aproduction-imagejob 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:wipeor--env=testingcommands: there is no.env.testing, and they would wipe the dev database. Only additivephp artisan migrate.
- 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:).
- Create a project from this repo, set up
.env, then follow the quick start. git remote add atrium [email protected]:DevPlus31/atrium.git. Pull improvements withgit fetch atrium && git merge atrium/main.- 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. - Drop the examples you don't need:
- Run
php artisan module:remove Shop --force(alsoCatalog,Api). - Then run
admin:sync-permissions,typescript:transform,wayfinder:generate --with-formandbun run build. - The module's tables are kept and listed; drop them with a migration when you want the data gone.
- Run
Multi-tenancy is deliberately left to each project. Atrium is single-tenant. Decide on tenancy before the first module.
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.
- Environment:
cp .env.production.example .env.production, then fill in every value its comments mark as required:APP_KEY,APP_URL, andDB_PASSWORD(the same value asPOSTGRES_PASSWORD).PASSKEYS_USER_HANDLE_SECRET: set it once, before anyone registers a passkey, and never change it.- The container refuses to start without
APP_KEYorPASSKEYS_USER_HANDLE_SECRET.
- 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.
- With
- 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), andTRUSTED_PROXIEStrusts only loopback and private ranges. - A load balancer on another machine needs
APP_BIND=0.0.0.0, its IPs inTRUSTED_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.
- The app port is bound to localhost (
- Start:
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build. - First admin:
docker compose --env-file .env.production -f docker-compose.prod.yml exec app php artisan admin:create-user. - 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 setconfig/privacy.php(consent categories, terms version, retention). - Remove the example modules (see "Using Atrium as a template").
- Decide on tenancy.
- 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-appvolume (uploads), or useMEDIA_DISK=s3.
- Schedule a database dump, e.g.
What the entrypoint does on every start:
- Checks the required secrets.
- Waits for the database.
- Runs
migrate --force --isolated(with several app replicas, one migrates) andadmin:sync-permissions. - 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):
pg_dumpallon the old version.- Stop the stack and remove the
postgres-datavolume. - Change the image and start the stack.
- Restore the dump.
MIT. Built on nunomaduro/laravel-starter-kit-inertia-react.