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.
What it is
Section titled “What it is”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 mirrors —
Country,Destination,Project,Unitand friends are fetched from the CRMs and stored locally (see theis_publishedmodel 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.
Architecture rules
Section titled “Architecture rules”These are the load-bearing rules from backend/CLAUDE.md §2. Breaking one is usually a regression,
not a style nit.
- 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. - Persona segregation matters. Routes are split per user type under
routes/api/*.phpand each group is protected byuser-provider:<persona>middleware. Putting a Shopper-only behaviour on a Broker route is a security regression. - Never call Dynamics directly. All CRM traffic must cross the
crms-middlewareHTTP boundary (env-prefixedCRMS_MIDDLEWARE_*). No code in this repo may import aRobusta\CrmIntegration\*class — the in-processmiddleware-packagereplacement is planned but not yet wired in, and mixing the two causes double-calls and double-bookkeeping. - PDF generation is a microservice call. Sales-offer PDFs go to
PDF_SERVICE_URL(defaulthttp://pdf-service:3001). Don’t spawn local Puppeteer or a PDF renderer here. - The
is_publishedCRM-mirror model. Peradr/03-crm-integration.md: CRM data is fetched, stored withis_published = false, then an admin enriches the null fields and flipsis_published = true. Only published rows ship to public APIs. Every CRM-sourced model must respect this. - Soft-deletion is the default. Per
adr/02-soft-deletion.md, new domain models useSoftDeletes(and cascade to children via theDeleteOneToManyRelationstrait) unless a hard-delete is defended in the PR. - Caching is response-cache-shaped.
spatie/laravel-responsecacheis wired up (adr/04-caching.md); GET endpoints tagged with thecacheResponsemiddleware are cached for a week and busted on any write to the CRM-mirror tables. Mind invalidation when you change a public endpoint. - Roles and permissions live with the
tcg/voyagerlayer plus custom middleware and aconfig/permissions.phpfile (adr/05-roles-permissions.md). Read that ADR before adding a permission or a custom Voyager action.
Layout
Section titled “Layout”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. |
Auth guard map
Section titled “Auth guard map”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) |
Running it locally
Section titled “Running it locally”Everything runs inside the docker app container (CLAUDE.md §3). Bring the stack up, then exec
into the container:
# Bring up the dev stackdocker-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.md—SoftDeleteseverywhere, cascading to children via a trait.Country,Destination,Project,Unitare exempt (they mirror the CRM and admins can’t delete them).03-crm-integration.md— the fetch → storeis_published = false→ admin enriches → publish flow. Detailed on/understand/crm-integration-model/.04-caching.md—spatie/laravel-responsecacheon GET endpoints, one-week TTL, busted on writes to the CRM-mirror tables.05-roles-permissions.md— Voyager roles for basic BREAD, plusconfig/permissions.phpfor custom actions, per-role editable fields, and theassignablestable that filters which countries/destinations/projects a role can see.
Foot-guns
Section titled “Foot-guns”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):
- Paymob HMAC validation uses
==instead ofhash_equals— timing-attack vulnerable. - Hardcoded API keys in some payment code paths (search
app/Services/). - Weak webhook timestamp validation — replay attacks possible.
- N+1 queries on broker analytics endpoints — verify with
barryvdh/laravel-debugbarif re-enabled. config()calls inside loops — cache the value once instead (performance regression).
