Skip to content

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 ​

PackagePurpose
lorisleiva/laravel-actionsEncapsulated executable classes (invoke as controller, job, command)
spatie/laravel-dataStrongly typed DTOs for request/response mapping
spatie/laravel-permissionRoles & permissions management
spatie/laravel-activitylogAuditing & activity logs
spatie/laravel-medialibraryMedia & file attachment handling
spatie/laravel-pdf + spatie/browsershotPDF generation (invoices, reports)
spatie/laravel-sluggableAutomatic slug generation
spatie/laravel-translatableTranslatable model attributes
spatie/laravel-typescript-transformerTS type generation
spatie/simple-excelCSV/XLSX import/export
foxbytehq/laravel-backed-enumsEnum metadata & TS transformation
tightenco/ziggyNamed route exposure to frontend
inertiajs/inertia-laravelServer-client bridge for SPA navigation
laravel/fortifyAuthentication backend (login, registration, 2FA, password reset)
laravel/sanctumAPI token & SPA auth guard
laravel/scout + meilisearch/meilisearch-phpSearch indexing & querying
laravel/horizonQueue dashboard & advanced management
laravel/reverbReal-time broadcasting (WebSockets)
laravel/pulse (+ pulse-outdated, pulse-4xx, pulse-schedule)Application performance & health metrics
laravel/nightwatchProduction monitoring agent
laravel/aiLLM integration used by app/Ai
sentry/sentry-laravelError & performance monitoring
nunomaduro/essentialsSane framework defaults (strict models, etc.)
predis/predisRedis client library
league/flysystem-aws-s3-v3S3 storage driver
symfony/mailgun-mailerTransactional email transport
rlanvin/php-rruleRecurrence rules for schedules
staudenmeir/eloquent-has-many-deepDeep relationship traversal
pelmered/fake-carVehicle test data generation
paquettg/php-html-parserHTML parsing utilities

Exact version constraints live in composer.json — treat that file, not this table, as the source of truth.

Application Bootstrapping ​

  • bootstrap/app.php registers middleware, routing, and exception handling (no Kernel.php, no Exceptions/Handler.php).
  • Service providers are declared in bootstrap/providers.php.
  • The web middleware stack appends HandleUserLocale, HandleInertiaRequests, AddLinkHeadersForPreloadedAssets.
  • The locale cookie 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 before asController(); a policy failure is a 403.
  • ::run() maps its arguments positionally to handle() — changing the handle() 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 $casts property.
  • Money columns are bigint cents in PostgreSQL and always cast with App\Casts\Money — never decimal/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:

  1. use App\Traits\ShopSearchable;
  2. Implement toSearchableArray() — always include shop_id.
  3. Add getSearchableRelations(): array for eager loading.
  4. Add index settings in config/scout.php under meilisearch.index-settings.
  5. sail artisan scout:sync-index-settings
  6. sail 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/Policies determine access per model or action.
  • spatie/laravel-permission attaches roles & direct permissions.
  • Prefer Gate::allows(), $this->authorize(), or an Action's authorize() method.
  • Whether the shop has a feature at all is a separate check: ->middleware('module:<value>') (EnsureShopHasModule, a 403) on routes, and the App\Facades\ShopModules facade (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/Listeners handle 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\Model sets $isBroadcasting to false, so a model stays silent until it sets it back to true. 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 through App\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.php defines the broker topology, config/comms.php the product settings.
  • Two daemons carry it: bus:outbox:relay publishes, bus:consume app.comms consumes. Both run as containers under Sail (bus-relay, bus-consumer); bus:declare sets 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-pdf on top of Browsershot, pointed at the chromium container (see config/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 HasMedia and uses InteractsWithMedia). Shop carries a single-file logo collection, 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 Inertia Error page for 403/404/500/503 outside local/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 for app/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 – runs typescript:transform then generate: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, so vp dev alone 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.