Appearance
Backend (Laravel 13)
Runtime Stack
- PHP 8.4 (
declare(strict_types=1);in every file) - Laravel Framework ^13.24
- PostgreSQL 17 (primary relational store)
- Redis (queue, cache, broadcasting)
- Meilisearch (full-text search via Scout)
- Horizon for queue processing & monitoring
- Reverb for WebSocket broadcasting
- Chromium (Browsershot) for PDF rendering
- S3-compatible storage via Flysystem (MinIO locally)
Core Packages
| Package | Purpose |
|---|---|
lorisleiva/laravel-actions | Encapsulated executable classes (invoke as controller, job, command) |
spatie/laravel-data | Strongly typed DTOs for request/response mapping |
spatie/laravel-permission | Roles & permissions management |
spatie/laravel-activitylog | Auditing & activity logs |
spatie/laravel-medialibrary | Media & file attachment handling |
spatie/laravel-pdf + spatie/browsershot | PDF generation (invoices, reports) |
spatie/laravel-sluggable | Automatic slug generation |
spatie/laravel-translatable | Translatable model attributes |
spatie/laravel-typescript-transformer | TS type generation |
spatie/simple-excel | CSV/XLSX import/export |
foxbytehq/laravel-backed-enums | Enum metadata & TS transformation |
tightenco/ziggy | Named route exposure to frontend |
inertiajs/inertia-laravel | Server-client bridge for SPA navigation |
laravel/fortify | Authentication backend (login, registration, 2FA, password reset) |
laravel/sanctum | API token & SPA auth guard |
laravel/scout + meilisearch/meilisearch-php | Search indexing & querying |
laravel/horizon | Queue dashboard & advanced management |
laravel/reverb | Real-time broadcasting (WebSockets) |
laravel/pulse (+ pulse-outdated, pulse-4xx, pulse-schedule) | Application performance & health metrics |
laravel/nightwatch | Production monitoring agent |
laravel/ai | LLM integration used by app/Ai |
sentry/sentry-laravel | Error & performance monitoring |
nunomaduro/essentials | Sane framework defaults (strict models, etc.) |
predis/predis | Redis client library |
league/flysystem-aws-s3-v3 | S3 storage driver |
symfony/mailgun-mailer | Transactional email transport |
rlanvin/php-rrule | Recurrence rules for schedules |
staudenmeir/eloquent-has-many-deep | Deep relationship traversal |
pelmered/fake-car | Vehicle test data generation |
paquettg/php-html-parser | HTML parsing utilities |
Exact version constraints live in composer.json — treat that file, not this table, as the source of truth.
Application Bootstrapping
bootstrap/app.phpregisters middleware, routing, and exception handling (noKernel.php, noExceptions/Handler.php).- Service providers are declared in
bootstrap/providers.php. - The web middleware stack appends
HandleUserLocale,HandleInertiaRequests,AddLinkHeadersForPreloadedAssets. - The
localecookie is exempt from encryption; no route is exempt from CSRF.
Actions
Actions replace heavy controllers. They use the AsAction trait (not a base class) and can serve HTTP, be queued, be run from the CLI, and validate their own input.
php
declare(strict_types=1);
namespace App\Actions;
use App\Models\Car;
use App\Models\Shop;
use Illuminate\Http\RedirectResponse;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class CreateCar
{
use AsAction;
public function rules(): array
{
return [
'vin' => ['required', 'string', 'size:17'],
'year' => ['required', 'integer'],
];
}
public function authorize(ActionRequest $request): bool
{
return $request->user()->can('create', Car::class);
}
public function handle(Shop $shop, string $vin, int $year): Car
{
return $shop->cars()->create(compact('vin', 'year'));
}
public function asController(ActionRequest $request): RedirectResponse
{
$this->handle(Shop::current(), ...$request->validated());
return back();
}
}Gotchas:
authorize()runs beforeasController(); a policy failure is a 403.::run()maps its arguments positionally tohandle()— changing thehandle()signature breaks every caller.
Models
All models extend App\Models\Model, which mixes in Userstamps, CascadeDeletes, HasIncrementingColumn, LogsActivity and BroadcastsEvents, and auto-casts the timestamp columns. Pivot models extend App\Models\Pivot.
Guidelines:
- Use the
casts()method instead of the$castsproperty. - Money columns are
bigintcents in PostgreSQL and always cast withApp\Casts\Money— neverdecimal/numeric. - Cascading deletes are declarative:
protected array $cascadeDeletes = ['lines'];. - Avoid fat models: push logic into Actions/Services.
- Eager-load relationships to mitigate N+1 (
->with([...])). - When modifying a column in a migration, restate all of its previous attributes or they are silently dropped.
Search (Scout + Meilisearch)
Searchable models use App\Traits\ShopSearchable (not the bare Scout Searchable) so indexes stay shop-scoped: Client, Car, WorkOrder, Product, Service, Supplier, PurchaseOrder.
Adding search to a new model:
use App\Traits\ShopSearchable;- Implement
toSearchableArray()— always includeshop_id. - Add
getSearchableRelations(): arrayfor eager loading. - Add index settings in
config/scout.phpundermeilisearch.index-settings. sail artisan scout:sync-index-settingssail artisan scout:import "App\Models\YourModel"
Validation
- Form Requests (
make:request) for incoming HTTP data. - Actions used as controllers validate through
rules()+ActionRequest. - DTOs (Spatie Data) carry sanitized & typed structures further into the domain.
Authorization
- Policies in
app/Policiesdetermine access per model or action. spatie/laravel-permissionattaches roles & direct permissions.- Prefer
Gate::allows(),$this->authorize(), or an Action'sauthorize()method. - Whether the shop has a feature at all is a separate check:
->middleware('module:<value>')(EnsureShopHasModule, a 403) on routes, and theApp\Facades\ShopModulesfacade (has(),withinLimit(),refusal()) in code. Pass the shop explicitly from jobs, automations and the client portal. See Plans & modules.
Events & Broadcasting
- Domain events in
app/Events. - Listeners in
app/Listenershandle side-effects (queue work, logs, notifications). - Broadcasting channels defined in
routes/channels.php. - Model broadcasting goes out on the per-instance channel
models.{morphClass}.{id}and never to the originating user. - Broadcasting is opt-in:
App\Models\Modelsets$isBroadcastingtofalse, so a model stays silent until it sets it back totrue. A channel and a payload alone broadcast nothing, and the failure is silent. - The payload carries identifiers only (
id,model,causer); clients refetch. Children reach a root record's channel throughApp\Traits\BroadcastsToParent, built from the foreign key rather than the relation. - Every model a page subscribes to needs a policy with
view()— the channel gate resolves to nothing without one and denies everyone.
See queues-realtime.md for the full picture.
Inter-Service Messaging
Email and SMS live in a separate service, comms-service. Provider webhooks (Twilio, Mailgun) hit that service's own hostname — this application exposes no webhook endpoints and holds no provider credentials.
The two services talk over RabbitMQ and nothing else; there is no HTTP call between them for anything that changes state.
app/Bus/—Outbox(transactional outbox),RabbitMqPublisher,RabbitMqConnection.app/Consumers/CommsEventConsumer.php— handles inbound delivery events.config/bus.phpdefines the broker topology,config/comms.phpthe product settings.- Two daemons carry it:
bus:outbox:relaypublishes,bus:consume app.commsconsumes. Both run as containers under Sail (bus-relay,bus-consumer);bus:declaresets up exchanges and queues.
This application still owns threads, messages, inbox queries, Reverb broadcasting and template rendering. The service owns delivery state, provider credentials, raw payloads and suppression lists.
See microservices/comms-service-design.md for the contract and microservices/runbook.md for operations.
PDF & Media
- Invoices & reports produced using
spatie/laravel-pdfon top of Browsershot, pointed at thechromiumcontainer (seeconfig/browser-shot.php). Which layout a work order renders with is the shop's choice — see Documents. - Media attachments saved through Media Library (model implements
HasMediaand usesInteractsWithMedia).Shopcarries a single-filelogocollection, inlined into PDFs as a data URI because the Chromium container cannot reach this app's storage.
Error Handling
- Exception behavior is configured in
bootstrap/app.php: Sentry integration, an InertiaErrorpage for 403/404/500/503 outsidelocal/testing, and a friendly redirect for 419.
Configuration Overview
Important config files:
config/inertia.php– Inertia root view & SSR settings.config/horizon.php/config/horizon-watcher.php– queue balancing, dashboard, dev auto-restart paths.config/reverb.php– WebSocket server settings.config/scout.php– Meilisearch connection & per-index settings.config/permission.php– role/permission caching.config/sentry.php– DSN & sample rates.config/pulse.php– metric recorders & dashboard access.config/typescript-transformer.php– type generation selectors.config/browser-shot.php– Chromium endpoint for PDF rendering.config/ai.php– model & provider settings forapp/Ai.config/bus.php– RabbitMQ topology for inter-service messaging.config/comms.php– communications product settings (number pricing, defaults).config/automations.php– automation triggers & dispatch settings.config/ziggy.php– which named routes reach the frontend.
CLI & Scripts
composer run transform-types– runstypescript:transformthengenerate:enum-registry.sail artisan shop:plan {slug} {plan},shop:module {slug} {module} --enable|--disable|--limit=N|--reset,shop:modules {slug}– assign a shop's plan, override one module, list what it resolves to. See Plans & modules.composer run dev– host-only convenience script (serve +queue:listen+ pail + vite). With Sail the app and Horizon already run in containers, sovp devalone is normally enough.
Performance Tips
- Use indexing & constraints in migrations explicitly.
- Avoid loading large media collections without pagination.
- Cache permission lookups via Spatie's internal cache (clear on role/permission changes).
- Offload heavy tasks to queues (PDF generation, external API calls, search indexing).
See frontend.md for SPA layer details.