explicatis/dynamics-api-bundle
Symfony bundle for integrating the Dynamics 365 API into PHP applications. It provides the groundwork for connecting a Symfony app to Microsoft Dynamics 365 services, keeping API configuration and service wiring inside the Symfony ecosystem.
This is a Symfony bundle, not a Laravel package. That single fact drives everything below: there is no service provider, no facade, no publishable config, and no config/dynamics.php. Composer will install it fine, but Laravel won't auto-discover or boot anything useful.
Minimal steps:
composer require explicatis/dynamics-api-bundleconfig/app.php providers — there's nothing to register.src/DependencyInjection/Configuration.php and src/DependencyInjection/*Extension.php first. With a README that is one line long, the bundle's config tree is your only source of truth for required keys — expect something like tenant ID, client ID, client secret, and a resource/base URL for https://{org}.crm{region}.dynamics.com/api/data/v9.2.AppServiceProvider using values from config/services.php + .env:$this->app->singleton(DynamicsClient::class, fn () => new DynamicsClient(
tenantId: config('services.dynamics.tenant'),
clientId: config('services.dynamics.client_id'),
clientSecret: config('services.dynamics.client_secret'),
baseUrl: config('services.dynamics.url'),
));
First use case: client-credentials auth against your tenant, then a read-only smoke test — GET /accounts?$select=name&$top=5. If that returns five account names, auth, base URL, and OData serialization are all working. Do this before writing a single line of domain code.
Wrap it, don't leak it. The package is a transport detail. Put it behind your own repository so a future swap (or a vendor fork) doesn't ripple through the codebase:
interface AccountRepository {
public function find(string $id): ?Account;
public function modifiedSince(CarbonImmutable $since): Collection;
}
final class DynamicsAccountRepository implements AccountRepository
{
public function __construct(private DynamicsClient $client) {}
}
Token caching is mandatory. Client-credentials tokens last ~60 minutes. If the bundle doesn't cache them (check — many thin wrappers don't), add a Cache::remember around the token fetch keyed on tenant+client, expiring at expires_in - 300. Re-authenticating per HTTP call will get you throttled fast.
Query building. Dynamics 365 exposes OData v4. Keep query construction in one place so encoding is consistent:
$query = [
'$select' => 'accountid,name,modifiedon',
'$filter' => "modifiedon gt 2024-01-01T00:00:00Z",
'$orderby' => 'modifiedon asc',
'$top' => 5000,
];
Use the HTTP client's query option to encode — never urlencode() a filter string yourself, or you'll double-encode $ and quotes.
Pagination. Responses cap at 5,000 rows and return @odata.nextLink. Loop until it's absent; treat it as an opaque absolute URL and pass it through unchanged.
Incremental sync. Keep a modifiedon watermark per entity in your own table. $filter=modifiedon gt {watermark} plus $orderby=modifiedon asc gives you a resumable pull. Overlap the window by a minute or two to survive clock skew, and de-duplicate on the entity ID.
Writes belong in jobs. Creates and updates are slow and rate-limited. Dispatch SyncAccountToDynamics jobs, and use the $batch endpoint for bulk — one HTTP round trip for up to ~100 operations instead of 100 throttled calls.
Hydrate into DTOs at the boundary. OData JSON uses logical names (accountid, _primarycontactid_value, new_customfield) and annotates lookups with @odata.bind. Map to your own value objects inside the repository so the rest of your app never sees _-prefixed keys.
The Symfony/Laravel impedance mismatch is the main risk. The bundle class itself implements Symfony\Component\HttpKernel\Bundle\Bundle and its extension consumes Symfony's DI config arrays. You can install it in Laravel and simply never boot the bundle — the non-bundle classes autoload normally. But if the client is constructed only via DI with array config, you'll be reverse-engineering the config tree to instantiate it by hand. Check this before committing to the package.
Transitive dependency conflicts. Symfony bundles commonly require symfony/http-client, symfony/options-resolver, or specific PSR-18 implementations, which can collide with Laravel's Guzzle version. Run composer why-not if install fails, and be prepared to pin or add symfony/http-client explicitly if the bundle expects it but doesn't require it.
Undocumented config keys. With a one-line README, Configuration.php is the contract. Read it, and write your own config mapping rather than guessing env var names.
OData sharp edges:
$metadata document once and keep it handy."[email protected]": "/contacts(guid)", not a plain GUID.Z; naive Y-m-d H:i:s silently fails or shifts.Throttling is real. Dynamics 365 returns 429 with Retry-After under service protection limits. Add retry middleware (3 attempts, honour Retry-After) on the underlying HTTP client — if the bundle doesn't expose its client, this alone may justify bypassing it.
Errors hide in the body. A failed call often returns a structured {"error": {"code": ..., "message": ...}}; don't rely on status codes alone. Log the full body and the x-ms-service-request-id response header — Microsoft support will ask for it.
Debugging. Install a logging middleware on the HTTP client and dump method, URL, and body for non-2xx responses in local/staging. Redact the Authorization header.
Extension points. Prefer decorating the client service over subclassing. If you must extend, the bundle's client class is the seam — but with zero stars and zero dependents, assume no upstream fixes are coming: pin the exact version in composer.json and be ready to fork into your own namespace if a Dynamics API version bump breaks it.
How can I help you explore Laravel packages today?