The CRM mock
crm-mock is a small Laravel 12 / PHP ^8.2 application whose only job is to pretend to be
the real CRMs during local development and testing. In production the platform talks to three
Microsoft Dynamics 365 orgs (Egypt, Oman, Montenegro) and their Azure Logic App write
endpoints through the crms-middleware service — see
the CRM integration reference for how that real stack is wired.
crm-mock slots in underneath the middleware, in place of those vendor systems, so you can
exercise the whole chain without a VPN, real credentials, or a live Dynamics tenant.
What it is
Section titled “What it is”A lightweight Laravel 12 app that returns canned responses shaped like the real CRMs, so
crms-middleware’s mapper layer sees the same payload structure it would get from Dynamics / the
Logic Apps. That shape is the contract — when a vendor changes their payload, the mock is meant to
change alongside the middleware mappers (crm-mock/CLAUDE.md sections 1 and 3).
Where it sits in the request chain (from crm-mock/PROJECT-OVERVIEW.md):
backend ──HTTP──▶ crms-middleware ──HTTP──▶ crm-mock (forward: reads + writes) ▲ │ └──────HTTP──────────────┘ (reverse: simulated CRM webhook, optional) crm-mock ──▶ crms-middleware /api/crms/*Stack, verified against the repo:
| Layer | Technology |
|---|---|
| Language | PHP ^8.2 (crm-mock/composer.json:9) |
| Framework | Laravel ^12.0 (crm-mock/composer.json:10) — newer than backend (10) and crms-middleware (9) |
| Database | SQLite by default (DB_CONNECTION=sqlite, crm-mock/.env.example) — no MySQL needed |
| HTTP client | Laravel Illuminate\Support\Facades\Http (for the reverse webhook) |
| Tests | PHPUnit ^11.5 (crm-mock/composer.json) |
| Assets | Vite (crm-mock/vite.config.js) |
| Container | crm-mock/docker/docker-compose-dev.yml |
What it fakes
Section titled “What it fakes”The complete mocked surface is crm-mock/routes/api.php. All routes are registered under the /api
prefix (crm-mock/bootstrap/app.php), so the full paths are:
| Method + path | Handler | What it returns |
|---|---|---|
POST /api/oauth2/token |
inline closure (routes/api.php) |
access_token: "fake-token", token_type: "Bearer", expires_in: 3599 — for any body |
POST /api/listing |
ListingController@handle |
A fixture file of pipelines or units, chosen by country + entityschemaname (bulk inventory read) |
POST /api/unit/payment-terms |
UnitPaymentTermsController@handle |
A fixture of payment plans, chosen by Unit.country |
POST /api/oman/brokers/register |
Oman\BrokerRegisterController@handle |
success: true plus a generated crm_broker_id; optionally fires the reverse webhook |
GET /api/oman/brokers/{crmBrokerId} |
Crm\BrokerGetController@show |
The Oman broker fixture with the requested crmBrokerId injected |
There is also a framework health check at GET /up (crm-mock/bootstrap/app.php).
The token endpoint
Section titled “The token endpoint”POST /api/oauth2/token returns the same object regardless of input:
{ "access_token": "fake-token", "token_type": "Bearer", "expires_in": 3599, "ext_expires_in": 3599 }Any client_id / client_secret you configure in the middleware works — the mock never validates
them.
Listing (bulk reads)
Section titled “Listing (bulk reads)”ListingController@handle reads the request’s Listing object and routes on two fields
(crm-mock/app/Http/Controllers/ListingController.php):
country—1 = Egypt,2 = Oman,3 = Montenegroentityschemaname—ldv_interest/new_unitfor Egypt and Oman;ldv_pipeline/ldv_unitfor Montenegro
The pair selects a fixture from crm-mock/storage/fixtures/<Country>/ (pipelines.json or
units.json). An unrecognised combination returns an empty array [].
Unit payment terms
Section titled “Unit payment terms”UnitPaymentTermsController@handle reads Unit.country and returns
crm-mock/storage/fixtures/<Country>/payment-plans.json for Egypt, Oman, or Montenegro. If the
country is unknown or the fixture is missing it returns PaymentPlans: []
(crm-mock/app/Http/Controllers/UnitPaymentTermsController.php).
Oman broker pre-registration
Section titled “Oman broker pre-registration”Two endpoints back the Oman broker flow (see also Oman broker pre-registration):
POST /api/oman/brokers/register— builds a broker id asCRM_+ a sanitised slug of the postedemailorphone, logs it, and returnssuccess,crm_broker_id, and a message. If the reverse webhook env vars are set (below) it then calls back intocrms-middleware(crm-mock/app/Http/Controllers/Oman/BrokerRegisterController.php).GET /api/oman/brokers/{crmBrokerId}— loadscrm-mock/storage/fixtures/Oman/crm-broker-get.json, overwritesdata.crm_broker_idwith the id from the URL, and rewrites everydata.documents[].document_urlto the localpublic/sample.pdfviaasset('sample.pdf')(crm-mock/app/Http/Controllers/Crm/BrokerGetController.php).
Wiring it up
Section titled “Wiring it up”The middleware decides per operation whether it hits the real CRM or the mock, purely through environment variables — the mock never has to know it is being used. There are two switching styles.
1. Point the CRM URLs at the mock (URL swap)
Section titled “1. Point the CRM URLs at the mock (URL swap)”crms-middleware/config/services.php builds each country’s CRM config from env keys. Point the
*_AUTH_URL and *_BASE_URL (and specific per-operation URLs) at the mock instead of Dynamics. In
crms-middleware/.env:
# Egypt org → mockEGYPT_CRM_AUTH_URL=http://host.docker.internal:8089/api/oauth2/tokenEGYPT_CRM_BASE_URL=http://host.docker.internal:8089/api# Oman org → mockOMAN_CRM_AUTH_URL=http://host.docker.internal:8089/api/oauth2/tokenOMAN_CRM_BASE_URL=http://host.docker.internal:8089/api# Unit payment terms operation → mockUNIT_PAYMENT_TERMS_CRM_URL=http://host.docker.internal:8089/api/unit/payment-terms# credentials can be any value — the mock returns "fake-token" regardlessEGYPT_CRM_CLIENT_ID=anythingEGYPT_CRM_CLIENT_SECRET=anythingThe relevant middleware keys are EGYPT_CRM_*, OMAN_CRM_*, and MONTENEGRO_CRM_*
(auth_url / base_url / client_id / client_secret / scope), plus operation URLs such as
UNIT_PAYMENT_TERMS_CRM_URL (crms-middleware/config/services.php, crms-middleware/.env.example).
Switching back to the real CRM is just restoring the real Dynamics / Logic-App URLs.
2. Use the middleware’s built-in mock toggle (Oman broker)
Section titled “2. Use the middleware’s built-in mock toggle (Oman broker)”For the Oman broker flow the middleware ships a first-class mock switch — no need to overwrite the
real Oman CRM config. In crms-middleware/.env:
OMAN_CRM_BROKER_CRM_MOCK_ENABLED=trueOMAN_CRM_BROKER_CRM_MOCK_BASE_URL=http://host.docker.internal:8089The middleware reads crms.oman.broker_crm_mock.enabled / .base_url
(crms-middleware/config/services.php) and, when enabled, routes the broker register/get calls to the
mock. A sibling toggle CRM_LEAD_ELIGIBLE_CRM_MOCK_ENABLED follows the same pattern for the
lead-eligibility check. This is the cleanest way to flip one flow to the mock while everything else
still points at real (or other-mock) CRMs.
3. Reverse direction — mock pushes a webhook into the middleware (optional)
Section titled “3. Reverse direction — mock pushes a webhook into the middleware (optional)”To test the inbound path (CRM → middleware → backend) without a real CRM trigger, set these in
crm-mock/.env (crm-mock/.env.example):
CRMS_MIDDLEWARE_BASE_URL=http://host.docker.internal:8000CRMS_MIDDLEWARE_CRM_WEBHOOK_CLIENT_ID=<middleware crm client id>CRMS_MIDDLEWARE_CRM_WEBHOOK_CLIENT_SECRET=<middleware crm client secret>When all three are present, BrokerRegisterController authenticates against
${CRMS_MIDDLEWARE_BASE_URL}/api/crms/login, then POSTs a broker payload to
${CRMS_MIDDLEWARE_BASE_URL}/api/crms/brokers/register
(crm-mock/app/Http/Controllers/Oman/BrokerRegisterController.php). If any of the three is missing the
webhook is silently skipped and only the normal registration response is returned.
Running locally
Section titled “Running locally”Standard Laravel setup (crm-mock/CLAUDE.md section 3, crm-mock/PROJECT-OVERVIEW.md section 6):
composer installcp .env.example .envphp artisan key:generatetouch database/database.sqlite # SQLite default DBphp artisan migrate
# all-in-one: HTTP server + queue worker + log tail (pail) + vitecomposer run dev
# or just the HTTP serverphp artisan serve # defaults to http://localhost:8000php artisan serve --port=8001 # pick a non-default port if 8000 is takenDocker option (crm-mock/docker/docker-compose-dev.yml): the nginx service publishes
8089:80, so the app is reachable at http://localhost:8089, and at
http://host.docker.internal:8089 (or the container name crm-mock-host-dev) from other containers on
the shared orascom-shared Docker network. Match whichever port you use to the *_BASE_URL values
above.
Useful checks:
php artisan route:list --path=api # list the mocked endpointsphp artisan test # PHPUnit 11Gotchas / limits
Section titled “Gotchas / limits”- Partial CRM coverage. The mock covers Egypt-style listing + payment terms and the Oman broker
register/get flow — plus Montenegro fixtures for listing and payment plans. It does not mock the
SalesMan or Link Dev surfaces, EOI, sale reservation, or most Logic-App writes. If you need those
locally, you extend this app (
crm-mock/CLAUDE.mdsection 4,KT-QUESTIONS.md). - Auth is fake.
oauth2/tokenalways returnsfake-tokenwith a fixedexpires_in. Any flow that depends on specific JWT claims, scopes, or token expiry will silently not be exercised (crm-mock/KT-QUESTIONS.mdQ6). - Static fixtures, no state machine. Responses are canned JSON files. The broker “get” endpoint just
echoes whatever
crmBrokerIdyou pass back into a single fixture and points every document atsample.pdf; it does not model a real register → upload → status progression (crm-mock/KT-QUESTIONS.mdQ9). - No filtering / pagination. Listing routes only on
country+entityschemaname; any paging or filter params the real CRMs honour are ignored (crm-mock/KT-QUESTIONS.mdQ7). PROJECT-OVERVIEW.mdis slightly ahead of the routes. It lists aPOST /api/oman/brokers/documentsendpoint (BrokerDocumentUploadController) that is not in the currentcrm-mock/routes/api.php; the live route instead isGET /api/oman/brokers/{crmBrokerId}. Trustroutes/api.php.- Drift is expected. Mappings and edge cases diverge from the real CRMs over time. Do not treat the
mock as a spec for how Dynamics behaves — the middleware mappers
(
crms-middleware/app/Mapper/<Vendor>/...) are the source of truth (crm-mock/CLAUDE.mdsection 5). - Not for CI unless you wire it up. It is primarily a human-driven local dev aid; whether CI spins it
up alongside the middleware is an open KT question (
crm-mock/KT-QUESTIONS.mdQ5, Q11).
Related
Section titled “Related”- The CRM integration reference — the real Dynamics 365 orgs + Logic Apps this mock stands in for.
- The crms-middleware service — the only component that talks to a CRM (real or mock), and where the mock/real switch lives.
- Oman broker pre-registration — the flow the broker endpoints support.
