microsoft/microsoft-graph-beta
Laravel SDK for the Microsoft Graph beta endpoint, giving your app fluent access to preview Microsoft 365 APIs like users, mail, files, Teams, and calendars, with built-in token caching, pagination, and typed models for faster development.
Know what you're installing. This is the generated model/builder library for the /beta Graph endpoint — it is not a turnkey client. It ships no HTTP transport and no auth provider. You need the Kiota companions alongside it:
composer require microsoft/microsoft-graph-beta \
microsoft/kiota-http-guzzle \
microsoft/kiota-serialization-json \
microsoft/kiota-serialization-form \
microsoft/kiota-serialization-multipart \
microsoft/kiota-serialization-text \
microsoft/kiota-authentication-phpleague
The generated package pins microsoft/kiota-abstractions to an exact range. If Composer resolves a newer abstractions release than the SDK was generated against, you get interface-mismatch fatals at runtime, not install time. After installing, open vendor/microsoft/microsoft-graph-beta/composer.json and pin the Kiota packages to the versions listed there rather than floating them.
First boot in a service provider. App-only (client credentials) is the simplest day-one path and the only one you can safely register as a singleton:
// app/Providers/GraphServiceProvider.php
use Microsoft\Graph\Beta\GraphServiceClient;
use Microsoft\Kiota\Authentication\Oauth\ClientCredentialContext;
use Microsoft\Kiota\Authentication\PhpLeagueAuthenticationProvider;
use Microsoft\Kiota\Http\GuzzleRequestAdapter;
$this->app->singleton(GraphServiceClient::class, function () {
$context = new ClientCredentialContext(
config('services.graph.tenant'),
config('services.graph.client_id'),
config('services.graph.client_secret'),
);
$auth = new PhpLeagueAuthenticationProvider(
$context,
['https://graph.microsoft.com/.default']
);
return new GraphServiceClient(new GuzzleRequestAdapter($auth));
});
Where to look first. Don't read the docs site for the PHP SDK — it lags badly. Instead:
/beta segment selected. Get a real 200 response./users/{id}/messages/{mid} becomes $client->users()->byUserId($id)->messages()->byMessageId($mid).vendor/microsoft/microsoft-graph-beta/src/Users/... — builder method names (byUserId vs byId, byMessageId vs byMessageId1) are the single most common source of "method does not exist" errors in this package.vendor/microsoft/microsoft-graph-beta/src/Models/. Everything is getter/setter based, never public properties.First useful call — send mail as a shared mailbox:
use Microsoft\Graph\Beta\Models\Message;
use Microsoft\Graph\Beta\Models\ItemBody;
use Microsoft\Graph\Beta\Models\Recipient;
use Microsoft\Graph\Beta\Models\EmailAddress;
use Microsoft\Graph\Beta\Users\Item\SendMail\SendMailPostRequestBody;
$to = new Recipient();
$to->setEmailAddress((new EmailAddress())->setAddress('[email protected]'));
$body = (new ItemBody())
->setContentType('html')
->setContent('Hello from the beta endpoint.');
$message = (new Message())
->setSubject('Your invoice')
->setBody($body)
->setToRecipients([$to]);
$request = (new SendMailPostRequestBody())->setMessage($message)->setSaveToSentItems(true);
$client->users()->byUserId('[email protected]')->sendMail()->post($request)->wait();
The ->wait() at the end is not optional — see Gotchas.
Separate app-only from delegated auth. The singleton pattern above works because a client-credentials token is tenant-wide and identical for every request. Delegated (per-user) auth is not: the AuthenticationProvider holds the user's token context, so construct a fresh GraphServiceClient per user/request. A clean split:
GraphServiceClient::class (singleton) → background jobs, daemons, webhook reconciliation, anything touching tenant-wide data.GraphClientFactory::forUser($user) method → returns a new client built from a stored refresh token. Inject the factory, not the client, into controllers.If you already have a token from Socialite or a stored refresh token, don't use PhpLeagueAuthenticationProvider — implement Microsoft\Kiota\Abstractions\Authentication\AccessTokenProvider with a one-method getAuthorizationToken(RequestInformation $requestInformation): Promise and hand it to a custom AuthenticationProvider. That's the intended extension point and it's about 20 lines.
Wrap the SDK in per-domain services. Never let GraphServiceClient leak into controllers. The fluent builder chain is verbose and the return types are generated interfaces that will churn between releases. A thin wrapper gives you a stable seam:
class GraphMailer
{
public function __construct(private GraphServiceClient $client) {}
public function send(string $mailbox, string $to, string $subject, string $html): void
{
$this->client->users()->byUserId($mailbox)->sendMail()
->post($this->buildRequest($to, $subject, $html))
->wait();
}
}
When a beta release renames something, you fix one class instead of grepping the app.
Use request configuration objects for OData, not string concatenation. This is the difference between readable code and a mess of query strings:
use Microsoft\Graph\Beta\Users\UsersRequestBuilderGetRequestConfiguration;
use Microsoft\Graph\Beta\Users\UsersRequestBuilderGetQueryParameters;
$params = new UsersRequestBuilderGetQueryParameters();
$params->select = ['id', 'displayName', 'mail', 'accountEnabled'];
$params->filter = "accountEnabled eq true and startswith(displayName,'A')";
$params->top = 50;
$params->count = true;
$config = new UsersRequestBuilderGetRequestConfiguration();
$config->queryParameters = $params;
$config->headers = ['ConsistencyLevel' => ['eventual']];
$page = $client->users()->get($config)->wait();
Note count = true requires the ConsistencyLevel: eventual header. Forgetting it produces a 400 Request_UnsupportedQuery that reads like your filter is wrong when it isn't.
Paginate deliberately. get() returns one page. Two options:
// Manual — gives you control over backoff and progress persistence
$page = $client->users()->get($config)->wait();
while ($page !== null) {
foreach ($page->getValue() as $user) { /* ... */ }
$next = $page->getOdataNextLink();
$page = $next ? $client->users()->withUrl($next)->get()->wait() : null;
}
For long tenant-wide syncs, prefer delta queries over pagination. Call $client->users()->delta()->get()->wait(), store getOdataDeltaLink() in the database, and resume from it later. This is dramatically cheaper than re-listing and is the pattern most Laravel apps actually need for "sync Microsoft users into my users table" jobs.
Batch related lookups in a queued job, not in the request cycle. Graph is throttled per-tenant and this SDK does not retry. Any loop over more than ~20 objects should be a job with $tries and backoff(), and you should catch ApiException for 429 and re-dispatch with the Retry-After delay yourself.
Test with Guzzle's mock handler, not Http::fake(). Because the SDK talks through its own Guzzle client, Laravel's HTTP fake never sees it. The workable pattern is to build the client with a mocked transport in tests:
$mock = new \GuzzleHttp\Handler\MockHandler([
new \GuzzleHttp\Psr7\Response(200, ['Content-Type' => 'application/json'], json_encode([
'value' => [['id' => '1', 'displayName' => 'Ada']],
])),
]);
$handler = \GuzzleHttp\HandlerStack::create($mock);
$guzzle = new \GuzzleHttp\Client(['handler' => $handler]);
// Only if your installed kiota-http-guzzle version accepts an injected client:
$adapter = new GuzzleRequestAdapter(new AnonymousAuthenticationProvider(), null, null, $guzzle);
Verify the adapter constructor signature against vendor/microsoft/kiota-http-guzzle/src/GuzzleRequestAdapter.php before relying on it — the optional-client parameter has changed across versions. If it isn't there, the reliable alternative is to mock your own service class and treat the SDK as infrastructure.
Beta-only features worth the risk. The reason to reach for /beta at all: presence (user availability), security alerts/incidents v2, identityGovernance access packages and lifecycle workflows, deviceManagement autopilot, and newer Teams/chat shapes. If you're only doing users, mail, calendar, or files, use the stable microsoft/microsoft-graph (v1) SDK — same namespaces modulo \Beta\, same builders, far less churn.
Forgetting ->wait() is the #1 silent failure. Every call returns a PromiseInterface; nothing is dispatched until you resolve it. Requests that "did nothing" and returned no error are almost always missing wait(). Lint for it by making your service methods void and always ending chains in ->wait().
Beta is genuinely unstable, and the release cadence is weekly. The last release date reflects a rolling generation pipeline, not a curated release. Method names, model properties, and even whole namespaces shift. Pin the exact version ("microsoft/microsoft-graph-beta": "1.x.y"), not a caret range, and treat upgrades as code changes requiring a test run. Do not let Dependabot auto-merge these.
Errors are opaque unless you decode them. Catch Microsoft\Kiota\Abstractions\ApiException, read getResponseStatusCode(), and deserialize the body into Microsoft\Graph\Beta\Models\ODataError to get getError()->getCode() and getMessage(). Raw $e->getMessage() on a Graph failure is usually just "The server returned an unexpected status code" with no detail — a recurring source of wasted hours.
Throttling is not handled for you. Graph returns 429 with Retry-After. The SDK surfaces it as an exception. Wrap your service calls in a retry with jitter, and expect 429s during backfills. Also expect 503s on beta endpoints during Microsoft-side deployments, which have nothing to do with your code.
Token caching matters more than you think. PhpLeagueAuthenticationProvider caches tokens in memory for the lifetime of the PHP process. Under PHP-FPM that's a single request, so every request re-authenticates against login.microsoftonline.com — adding 100–300 ms per call. Implement Microsoft\Kiota\Authentication\Cache\AccessTokenCache against Laravel's cache store and pass it in, or accept the latency and cache at a higher level (your own resolved client per request is already one auth, so batch your Graph work per request).
Beta endpoints frequently require admin consent and delegated-only scopes. A 403 Forbidden with "Insufficient privileges to complete the operation" almost always means a missing application permission + admin consent grant in the Azure portal, not a code bug. Beta scope names (e.g. Mail.Send vs Mail.Send.Shared) are less forgiving than v1 and change between previews.
Enum parameters want enum instances in newer Kiota generations, strings in older ones. Check the model's setter signature before passing 'html' for ItemBody::setContentType(). If the setter is typed to an enum class, passing a raw string throws a TypeError that reads like a Graph failure.
Everything is nullable. getValue() can return null on an empty collection in some builders, and almost every model property can be null even when Graph documents it as required. Use ?? [] defensively around getValue() and null-coalesce optional fields — beta payloads omit properties more often than v1.
Namespace collisions with the v1 SDK. Microsoft\Graph\Models\User and Microsoft\Graph\Beta\Models\User are different classes. If you use both packages (common during a migration), alias your imports explicitly (use Microsoft\Graph\Beta\Models\User as BetaUser;) — IDE auto-import will silently pick the wrong one and you'll get confusing type errors at the call site.
Autoload cost is real. This package is tens of thousands of generated classes. Run composer dump-autoload -o in production (and consider --classmap-authoritative behind opcache). Don't let PHPStan or Pint scan vendor/ — exclude it, or you'll add minutes to CI for zero signal.
Set the base URL for sovereign clouds. Default is https://graph.microsoft.com/beta. For GCC High or China, call $adapter->setBaseUrl('https://graph.microsoft.us/beta') (or the appropriate host) on the request adapter before constructing the client — it's on the adapter, not the service client.
Extension point for observability. Since the adapter builds its own Guzzle stack, you can't drop in Laravel's logging middleware. Either inject your own Guzzle client (if supported by your version) or decorate GraphServiceClient usage in a service that times and logs around ->wait(). Log the request path and status code — Graph support tickets will ask for exactly that.
If you're only hitting one or two endpoints, skip the SDK. Http::withToken($token)->post('https://graph.microsoft.com/beta/users/.../sendMail', $payload) is 5 lines, fully testable with Http::fake(), and immune to beta SDK churn. The SDK earns its weight when you need typed models across many resources, deep OData query construction, or delta/pagination handling — and not before.
How can I help you explore Laravel packages today?