microsoft/microsoft-graph
The Microsoft Graph SDK for PHP lets you authenticate with Azure AD and call Microsoft 365 APIs to work with users, mail, files, calendars, and Teams from your PHP apps. Requires PHP 8.2+ and Composer.
Understand what this is first: microsoft/microsoft-graph is not a Laravel package. It is a Kiota-generated OpenAPI client for the Microsoft Graph REST API — thousands of generated request builders and models, no service provider, no facade, no config file, no artisan command. Day-to-day you treat it like an HTTP client you happen to have typed classes for. Most of your integration work is wiring, caching tokens, and wrapping it so controllers never see Kiota types.
Install and pin.
composer require microsoft/microsoft-graph:2.48.*
Requires PHP 8.2+. Pin an exact minor — the SDK is regenerated from Graph metadata twice a month, so ^2 will silently move request-builder and model surfaces under you.
Where to look first, in this order:
README.md — the auth contexts and the ->wait() idiom.docs/README.md and docs/authentication_samples.md (especially access token management and creating a graph client).UPGRADING.md before any major bump.src/. It is generated; reading it tells you about Kiota, not about Graph. Use Graph Explorer (aka.ms/ge) to prototype the URL and confirm the permission it needs.First use case — app-only, from a queued job. Register a singleton so credentials and token state are shared per request:
// config/graph.php
return [
'tenant_id' => env('GRAPH_TENANT_ID'),
'client_id' => env('GRAPH_CLIENT_ID'),
'client_secret' => env('GRAPH_CLIENT_SECRET'),
'scopes' => ['User.Read.All', 'Mail.Send'],
];
// AppServiceProvider::register()
$this->app->singleton(GraphServiceClient::class, fn () => new GraphServiceClient(
new ClientCredentialContext(
config('graph.tenant_id'),
config('graph.client_id'),
config('graph.client_secret'),
),
config('graph.scopes'),
));
Then inject it, but wrap it immediately (see next section) so you never call ->wait() from a controller:
$user = $graph->users()->byUserId($upn)->select(['id', 'displayName', 'mail'])->get()->wait();
echo $user->getDisplayName();
That single call — "look up a user, then send a mail / invite to a Teams meeting / grant a licence" — covers 80% of real Laravel usage of this SDK.
Isolate the client behind your own class. The generated API changes shape on regeneration; your app shouldn't. One thin service (App\Services\Graph\GraphMailer, GraphUsers) that owns ->wait(), error mapping, and retries, plus an interface you can fake in tests. Everything downstream depends on your interface, not on Microsoft\Graph\*.
Pick the auth context by permission model, not by convenience.
ClientCredentialContext — app-only, no signed-in user. Cannot use /me, cannot use User.Read. Needs Application permissions with tenant-wide admin consent.AuthorizationCodeContext — delegated, acts as the user. Needed for /me, /me/messages, OneDrive personal, Teams chat-as-user.OnBehalfOfContext — service calling Graph on behalf of a user who is already authenticated to your app (typically an API/bot scenario).Handle the delegated flow the Laravel way. Redirect the user to https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize with your client_id, redirect_uri, scope, state (store state in session), then exchange the returned code in your callback for tokens and persist them. Many teams skip the SDK here and use Socialite's Azure driver to obtain the code/tokens, then hand a pre-acquired token to the client — see the "initialising with an already acquired access token" sample in docs/authentication_samples.md.
Token caching is the pattern that bites. The auth provider caches tokens in memory per PHP process. Under PHP-FPM that means a fresh token request roughly every request; under queue workers it's better but still lost on restart. For real workloads, supply a persistent token cache (implement the auth library's cache interface against Laravel's Cache/Database store, keyed by tenant+client+scopes) or store the delegated refresh token encrypted on the user model and refresh proactively. Also: ->default scopes for app-only, explicit scopes for delegated — build two client singletons if you need both.
Query building. Request builders expose OData parameters as fluent calls; always select() narrowly and top() deliberately:
$graph->users()
->filter("accountEnabled eq true and startsWith(mail,'eng-')")
->select(['id', 'mail', 'department'])
->top(50)
->get()->wait()->getValue(); // array of User models
Paging. Collection responses give you @odata.nextLink; either loop on getOdataNextLink() or use the SDK's PageIterator helper (in Microsoft\Graph\Core\Tasks) for a lazy callback per page. Never fetch all users in one shot. For incremental sync (users, mail, drive items), use a delta query and persist the deltaLink — that is the difference between a 2-second cron and a 40-minute one.
Batching. Microsoft\Graph\Core\Requests\BatchRequestContent / BatchResponseContent let you send up to 20 requests in one HTTP call. Use it for "get photos for 50 users" or "read presence for the whole team". It cuts throttling dramatically and is the single highest-leverage optimisation with this SDK.
Writes are just models plus post/patch. Generated models use setters/getters and accept property arrays in the constructor. Send mail from a service mailbox:
$message = new Message();
$message->setSubject('Your invoice is ready');
$message->setBody(new ItemBody(['contentType' => BodyType::TEXT, 'content' => $body]));
$message->setToRecipients([new Recipient(['emailAddress' => new EmailAddress(['address' => $to])])]);
$graph->users()->byUserId($sender)->sendMail()->post($message)->wait();
Rate limiting and queues. Put every bulk or write operation on a queued job. Use RateLimiter::for('graph', ...) for your own concurrency ceiling, and honour 429/Retry-After with exponential backoff ($job->release($seconds)).
Testing. Don't hit Graph in tests. Bind a fake implementation of your own interface. If you must exercise the SDK itself, point the request adapter at a mock HTTP client (Kiota's RequestAdapter interface allows this), or run against the Graph developer proxy / mocked Graph responses rather than a real tenant.
When to drop to raw HTTP. For a single endpoint, or anything only in beta, Http::withToken($token)->get('https://graph.microsoft.com/v1.0/...') is often less code than navigating the generated tree. Keep both: SDK for typed CRUD, raw Http for one-offs and webhook-driven work. If you need beta models, there is a separate Microsoft\Graph\Beta\GraphServiceClient — don't mix it into production code without a reason.
->wait(). Every operation returns a Promise. Without wait() nothing happens, and you get a promise object where you expected a model — usually surfacing later as a confusing null or a JSON-encode error. Standardise on ->get()->wait() inside one wrapper class.User.Read cannot be granted app-only — use User.Read.All or User.ReadBasic.All. App-only Mail.Send additionally requires an ApplicationAccessPolicy in Exchange to scope the mailbox, otherwise it fails with an unhelpful error./me requires delegated auth, full stop. Using it with ClientCredentialContext returns a 400/403 that reads like a permissions problem.Microsoft\Kiota\Abstractions\ApiException and log getResponseStatusCode(), getError()->getCode(), getError()->getMessage(). OData errors nest; the top message is often generic.Retry-After is authoritative — respect the header rather than a fixed backoff. Log request-id and client-request-id from response headers; Microsoft support asks for them, and they're your only trace into their side.hireDate, birthday etc. as date-only; the SDK models them as its own Date type, not \DateTime. Passing a Carbon instance where a Date is expected causes serialisation errors that look like schema mismatches.$filter is a string expression. Never interpolate user input raw; quote and escape single quotes (O'Neil → O''Neil) or use byUserId-style addressable paths instead.select() aggressively and avoid expand('manager') inside loops — that's an N+1 against a throttled API.composer install and dump-autoload -o are slow; keep classmap-authoritative autoloading and OPcache on, and expect CI images to grow. Consider installing it only in the service that needs it if you run a monorepo.UPGRADING.md on majors, and never upgrade this dependency the afternoon before a release. v1 and v2 APIs are fundamentally different ($graph->createRequest() vs Kiota builders) — don't copy v1 snippets from blog posts into a v2 codebase.graph.microsoft.us, microsoftgraph.chinacloudapi.cn, etc.). Changelog entries show this has been a repeated bug source; verify the base-URL parameter is actually applied before trusting a multi-cloud deployment.AccessTokenProvider (for tokens minted elsewhere, e.g. a shared auth service), a custom token cache, and a decorator around your own service classes for logging/metrics. That's where you customise — not by editing or subclassing generated builders.How can I help you explore Laravel packages today?