Skip to content

The backend repo

The backend repo is the centre of the platform: one Laravel 10 / PHP 8.2 API plus a Voyager admin panel. Every other repo either calls it (the four React apps) or is called by it (crms-middleware, the PDF service, the payment gateways). If you only learn one repo, learn this one.

A single Laravel application that serves four React SPAs (Shopper, Broker, Sales-Man, Shopper-Analytics — all in the sister frontend repo) and hosts the Voyager admin at /admin. It owns:

  • User accounts for every persona (shopper, broker, sales-man, sales-manager, shopper-admin, plus admin users).
  • Local CRM mirrorsCountry, Destination, Project, Unit and friends are fetched from the CRMs and stored locally (see the is_published model below).
  • Business logic — leads, sales, EOIs, commissions, meetings, promotions, notifications.
  • Payments — Paymob, Thawani and WSPG orchestration.
  • Outbound integrations — CRM traffic (via the middleware), PDF generation, SMS, email, Firebase, analytics.

These are the load-bearing rules from backend/CLAUDE.md §2. Breaking one is usually a regression, not a style nit.

  1. Controllers stay thin. Real business logic lives in app/Actions/* as single-action invokables. Don’t put logic in controllers — a controller injects an action and returns its result.
  2. Persona segregation matters. Routes are split per user type under routes/api/*.php and each group is protected by user-provider:<persona> middleware. Putting a Shopper-only behaviour on a Broker route is a security regression.
  3. Never call Dynamics directly. All CRM traffic must cross the crms-middleware HTTP boundary (env-prefixed CRMS_MIDDLEWARE_*). No code in this repo may import a Robusta\CrmIntegration\* class — the in-process middleware-package replacement is planned but not yet wired in, and mixing the two causes double-calls and double-bookkeeping.
  4. PDF generation is a microservice call. Sales-offer PDFs go to PDF_SERVICE_URL (default http://pdf-service:3001). Don’t spawn local Puppeteer or a PDF renderer here.
  5. The is_published CRM-mirror model. Per adr/03-crm-integration.md: CRM data is fetched, stored with is_published = false, then an admin enriches the null fields and flips is_published = true. Only published rows ship to public APIs. Every CRM-sourced model must respect this.
  6. Soft-deletion is the default. Per adr/02-soft-deletion.md, new domain models use SoftDeletes (and cascade to children via the DeleteOneToManyRelations trait) unless a hard-delete is defended in the PR.
  7. Caching is response-cache-shaped. spatie/laravel-responsecache is wired up (adr/04-caching.md); GET endpoints tagged with the cacheResponse middleware are cached for a week and busted on any write to the CRM-mirror tables. Mind invalidation when you change a public endpoint.
  8. Roles and permissions live with the tcg/voyager layer plus custom middleware and a config/permissions.php file (adr/05-roles-permissions.md). Read that ADR before adding a permission or a custom Voyager action.

Where things live (all paths relative to backend/):

Directory What lives here
app/Actions/ Single-action invokables — the largest behavioural surface. Grouped by domain (Lead/, Sale/, Broker/, Unit/, Sync/, …).
app/Http/Controllers/Api/<Persona>/ Thin controllers, one namespace per persona (Broker/, Shopper/, SalesMan/, SalesManager/, ShopperAdmin/, CrmsMiddleware/, Auth/).
app/Http/Requests/ FormRequest validation classes.
app/Models/ Eloquent domain models — start with Country, Destination, Project, Unit, Lead, Sale, Broker, Shopper.
app/Clients/ Concrete outbound HTTP clients (e.g. CrmMiddlewareClient, the mail-authenticated clients) extending AbstractClient.
app/Interfaces/ The client interfaces the clients implement — bind fakes here in tests.
app/Services/ Multi-step domain services (payments, currency rates, permissions, exports, Firebase auth, SMS).
app/ResponseMapper/ Response shaping so every payload is wrapped consistently ({ "data": ..., "message": ... }).
app/Enums/ Domain enums including AuthTokenAbilities (the Sanctum ability constants).
app/Observers/, app/Jobs/, app/Notifications/ Model side-effects, queued work, and notification channels.
routes/api.php + routes/api/*.php Public/shared routes plus one file per persona (see the guard map below).
config/services.php External-service config (crms_middleware, pdf_service) populated from .env.
config/permissions.php Custom-action and per-role editable-field rules for the Voyager admin.
adr/ The five Architecture Decision Records.
docs/openapi.yaml The full API contract — every new endpoint must be added here.

Persona segregation is enforced at the route-group level. Each group rebinds the web guard/provider via user-provider:<persona> so a token only authenticates against its own model (CLAUDE.md §7).

Route group Guard / middleware
routes/api.php (login, currency, paymob) mostly public; some auth:sanctum
routes/api/broker.php auth:sanctum + user-provider:brokers
routes/api/shopper.php Sanctum + user-provider:shoppers
routes/api/sales-man.php Sanctum + user-provider:sales_man
routes/api/sales-manager.php Sanctum + user-provider:sales-manager
routes/api/shopper-admin.php Sanctum + user-provider:shopper-admin
routes/api/crms-middleware.php Passport client_credentials (the middleware’s OAuth client)

Everything runs inside the docker app container (CLAUDE.md §3). Bring the stack up, then exec into the container:

Terminal window
# Bring up the dev stack
docker-compose -f docker/docker-compose-dev.yml --env-file .env up -d
# Alias the app container (handy if you'll be here a while)
DC="docker-compose -f docker/docker-compose-dev.yml exec app"
$DC composer install
$DC php artisan migrate
$DC php artisan db:seed
$DC php artisan test # PHPUnit feature + unit suites
$DC vendor/bin/phpstan analyse # static analysis (larastan)
$DC vendor/bin/php-cs-fixer fix # code style
$DC php artisan route:list --path=api # see the API surface (very long)
$DC php artisan optimize:clear # bust caches
$DC php artisan queue:work # process jobs (sync queue is fine in dev)

vite here builds Voyager admin assets only — the user-facing apps live in the separate frontend repo. A test database named orascom_testing must exist before php artisan test (CLAUDE.md §8). See the full walkthrough on /tutorials/run-the-stack-locally/.

The decisions, in backend/adr/ — short but load-bearing:

  • 01-admin-panel.md — chose Voyager for the admin CRUD/BREAD layer. Trade-off: Voyager config is written to the DB from its own UI, so it isn’t git-tracked.
  • 02-soft-deletion.mdSoftDeletes everywhere, cascading to children via a trait. Country, Destination, Project, Unit are exempt (they mirror the CRM and admins can’t delete them).
  • 03-crm-integration.md — the fetch → store is_published = false → admin enriches → publish flow. Detailed on /understand/crm-integration-model/.
  • 04-caching.mdspatie/laravel-responsecache on GET endpoints, one-week TTL, busted on writes to the CRM-mirror tables.
  • 05-roles-permissions.md — Voyager roles for basic BREAD, plus config/permissions.php for custom actions, per-role editable fields, and the assignables table that filters which countries/destinations/projects a role can see.

The audit (../audit/ARCHITECTURE_REMEDIATION_PLAN.md) flagged these as known Phase-1 issues — check the audit doc before touching payment or analytics code (CLAUDE.md §10):

  1. Paymob HMAC validation uses == instead of hash_equals — timing-attack vulnerable.
  2. Hardcoded API keys in some payment code paths (search app/Services/).
  3. Weak webhook timestamp validation — replay attacks possible.
  4. N+1 queries on broker analytics endpoints — verify with barryvdh/laravel-debugbar if re-enabled.
  5. config() calls inside loops — cache the value once instead (performance regression).