Add a new CRM operation
Adding a CRM operation means adding code to crms-middleware (the only service that talks to the
CRMs) and a caller on the backend. Before you touch anything, make the one decision that determines
the whole shape — read the CRM integration model first if it isn’t
fresh. This recipe uses the Lead entity as the worked example (a write and a read), verified against
crms-middleware @ develop.
Write op — worked example: submit a broker lead
Section titled “Write op — worked example: submit a broker lead”The request travels: route → controller → action (map + wrap + call) → client → Logic App, then the response is normalised back.
- Route —
POST portals/brokers/{brokerSourceId}/leads→BrokerController@submitNewLead(routes/api.php:86), inside theprefix('portals')+client:portals-apigroup (:23). - Controller —
Api/Portals/BrokerController.php:33-46injects the request-validation, the concreteLeadClient, theSubmitNewLeadaction, andCountryName $country(resolved from the header — see below); calls the action, then$this->commonResponse(...). - Action —
Actions/Crms/Leads/SubmitNewLead.php:19-57: resolve the numeric country id (Util::getCountrySourceId), run the request Mapper, merge['country' => <id>], wrap as['Lead' => $mapped], call$client->submitLead(...), and pipe the result throughFormatCrmResponse(pullingLead.leadid/Lead.customeridinto aCrmResponseDto). - Client —
Clients/Crms/LeadClient.php:10-15:submitLead()reads the Logic-App URL fromconfig('services.crms.lead.submit_new_lead_url')and callspostWithAuthentication(url, data).LeadClient extends LinkDevAuthenticatedClient. - Auth + transport —
LinkDevAuthenticatedClientdoes the OAuth v1resource=grant (token cached aslinkdev-crm-token);AbstractClient::postWithAuthenticationPOSTs with a 300 s, single-attempt timeout.
The request Mapper (Mapper/SubmitNewLeadRequestMapper.php:19-40) is a Dict schema translating the
portal’s snake_case to Dynamics logical names — first_name→firstname, phone→fullmobilenumber,
budget→clientbudget, broker_source_id→salesagentid, destination_source_id→destinationid,
interested_in_unit_id→interestedin. Broker/shopper lead writes share one country-agnostic request
mapper (only the numeric country differs); per-country request mappers exist only for the Sales-Man
surface.
Config + env (config/services.php): the write URL is services.crms.lead.submit_new_lead_url ←
LEAD_CRM_SUBMIT_NEW_LEAD_URL; the write auth is services.crms.link_dev_auth ← LINK_DEV_AUTH_URL /
_CLIENT_ID / _CLIENT_SECRET / _GRANT_TYPE / _RESOURCE.
Files you touch (write op, in order)
Section titled “Files you touch (write op, in order)”config/services.php— addservices.crms.<group>.<op>_url(+ the*_URLenv name).Clients/Crms/<Thing>Client.php— add the method (on aLinkDevAuthenticatedClientsubclass) reading that URL.Mapper/<...>RequestMapper.php— add the unified→DynamicsDict(skip if no field translation).Actions/Crms/<Domain>/<Verb><Thing>.php— map → mergecountry→ wrap → call client →FormatCrmResponse.Http/Controllers/Api/Portals/<X>Controller.php— injectCountryName+ validation + client + action; returncommonResponse.routes/api.php(prefix('portals')) — add the route.docs/portals-openapi.yml— document the inbound contract.- Backend side — add/extend a
CrmsMiddleware*client action + persona route that calls it with thecountryheader (see below).
Read op — worked example: list a broker’s leads
Section titled “Read op — worked example: list a broker’s leads”The read goes straight to the per-country Dynamics org over OData; the container picks the right
country class from the country header.
- Route —
GET portals/brokers/{brokerSourceId}/leads→BrokerController@listLeads(routes/api.php:91). - Controller —
Api/Portals/BrokerController.php:63-71injects the interfaceGetBrokerLeadsInterface $client(the container resolves it to the right country class) +GetLeadsList. - Action —
Actions/Crms/Leads/GetLeadsList.php:10-32injects a per-countryBrokerLeadsMapperInterface+FormatCrmResponse; calls$client->get(...), and on 200 runs$responseMapper->map($body). - Per-country reader —
Clients/Crms/EgyptCrmClient/GetBrokerLeads.php:10-63(extends CrmClient) builds an OData query against{baseUrl}/api/data/v9.2/leadswith$select/$filter/$expand/$orderby. The Oman/Montenegro siblings hit the same entity but a different field dialect (e.g. Egypt selectsldv_desireddestination_*; Oman selects_ldv_destination_value) — which is exactly why the reader is per-country, not shared. - Base OData client + auth —
Clients/Crms/CrmClient.phpbuilds itsCrmCredentialsfrom the container and does the OAuth v2scope=grant (token cached per country). - Response mapper —
ResponseMapper/<Country>/BrokerLeadsMapper.php(Dynamics→unified), all implementingResponseMapper/Country/BrokerLeadsMapperInterface.php.
Config + env: per-country read creds live at services.crms.egypt|oman|montenegro
(config/services.php:34-69) ← {EGYPT|OMAN|MONTENEGRO}_CRM_{AUTH_URL,CLIENT_ID,CLIENT_SECRET,GRANT_TYPE, SCOPE,BASE_URL}. base_url is the Dynamics org root the reader appends /api/data/v9.x/… to.
Files you touch (read op, in order)
Section titled “Files you touch (read op, in order)”Clients/Crms/CrmResourcesContracts/Get<Thing>Interface.php— the contract.Clients/Crms/<Country>CrmClient/Get<Thing>.php— one per supported country (extends CrmClient; mind the OData version + logical names).ResponseMapper/Country/<Thing>MapperInterface.php+ResponseMapper/<Country>/<Thing>Mapper.php— one mapper per country.Providers/AppServiceProvider.php— register the client interface incountryCrmClientResolveMap()and the mapper interface incountryResponseMapperResolveMap().Actions/Crms/<Domain>/Get<Thing>.php— inject the mapper interface +FormatCrmResponse; call client; map on 200.Http/Controllers/Api/Portals/<X>Controller.php+routes/api.php+docs/portals-openapi.yml.- Backend side — consumer action + route as above.
How the country header picks the right code
Section titled “How the country header picks the right code”For a write, the numeric country goes into the body: Util::getCountrySourceId →
CrmCountryIdConverter::mapToCrm → CountryName::getKey → egypt=1, oman=2, montenegro=3 (a plain
ordinal, not a GUID). For a read, the container swaps the whole class. Both start from
AppServiceProvider::getCountryFromHeadersOrFail() (:295-304), which reads header('country') and
CountryName::tryFrom(...). Then scopeCountry() (:347-369) binds — per the resolve-maps — the
country-specific concrete class for every interface:
countryCrmClientResolveMap()— the read clients (GetBrokerLeadsInterface → Egypt/Oman/Montenegro).countryResponseMapperResolveMap()— the response mappers.countryMapperResolveMap()— per-country request mappers (Sales-Man only).
Gotchas an engineer must know
Section titled “Gotchas an engineer must know”- Single attempt, 300 s, no retry.
AbstractClientsets->timeout(300)->connectTimeout(300)and has no->retry(...)— one shot per call; a slow Logic App / Dynamics query blocks up to 5 minutes then fails hard. - Non-200 from the CRM → HTTP
400(FormatCrmResponse+ApiController::commonResponse), with the CRM body JSON-encoded intomessage— except two exceptions that surface as500:UnsupportedCountryException— an accidental 500: it passesHTTP_NOT_FOUNDas the exception code (not a status) and has norender(), so Laravel emits a generic 500. An unknown/missingcountryheader looks like a 500, not a 404. (Tracked as a bug — see the KT tracker.)UnsupportedClassForCountry— a deliberate 500 (constructs withHTTP_INTERNAL_SERVER_ERROR+ has arender()), thrown when the op has no class for the requested country.
- Auth differs by direction; OData version differs by resource. Writes use the Link-Dev v1
resource=grant; reads use per-country v2scope=. The OData version is chosen per entity in the reader URL (leads→ v9.2,*units→ v9.1) — don’t assume one version org-wide. All of it authenticates against the same Azure AD app. - Never share mappers across CRMs, never share clients across directions — the Dynamics dialects genuinely differ per country, and the write/read auth surfaces are separate.
- Local dev uses
crm-mock. Point<CRM>_BASE_URLat thecrm-mockapp; there’s also an Oman broker-CRM mock swap (OMAN_CRM_BROKER_CRM_MOCK_ENABLED).
The backend caller (who invokes the middleware)
Section titled “The backend caller (who invokes the middleware)”The portal backend reaches the middleware via CrmMiddlewareClient — a Passport client_credentials
grant against CRMS_MIDDLEWARE_AUTH_URL (token cached as crms-middleware). Every outbound call sets the
country header from the country slug — that header is exactly what the middleware’s scopeCountry
bindings read. So the backend contract for any new op: call the matching crms-middleware route through a
CrmsMiddleware* client action with client_credentials auth and the country header, and keep the route
signature in sync with what the backend sends.
Test / verify
Section titled “Test / verify”- Static + route —
phpstan analyse, thenphp artisan route:listto confirm the newportals/…route sits underclient:portals-api. (Note: the resolve-maps are plain arrays, so a wrong interface/country is a runtimeUnsupportedClassForCountry, not a static error — verify the map entry by eye.) - Local run against
crm-mock— point<CRM>_BASE_URLatcrm-mock; send the request with a validclient:portals-apitoken and thecountryheader (omit it → the 500 gotcha), and assert the mapped unified shape (write →{lead_source_id, customer_source_id}; read → the mapper’s fields). - Per-country matrix — exercise each country you registered; a missing map entry 500s only for that country.
- Contract — update + lint
docs/portals-openapi.yml; keep the backendCrmsMiddleware*client signature in sync.
