Integrate GraphWarden with a PHP or Laravel application
A PHP or Laravel application using microsoft/microsoft-graph (the official Microsoft Graph PHP SDK) can route every Graph call through GraphWarden by overriding two values: the token endpoint and the Graph base URL. The rest of the SDK surface — GraphServiceClient request builders, pagination, $select and $filter — keeps working unchanged. This page shows a plain-PHP working sample and a Laravel service-container binding.
Prerequisites
Migrate a PHP or Laravel application with an AI assistant
Migrate my existing PHP or Laravel application from direct Microsoft Graph API calls to use GraphWarden as a proxy.
My app currently uses microsoft/microsoft-graph with https://graph.microsoft.com.
I need to:
1. Change the base URL to my GraphWarden proxy URL
2. Replace real Graph credentials with GraphWarden proxy credentials
3. Preserve all existing Graph SDK calls unchanged
4. Add error handling for GraphWarden-specific responses (401 invalid proxy credentials, 403 ruleset denial)
Reference documentation: https://graphwarden.com/llms.txt
Ask me for:
- My Laravel version (or "plain PHP" if not using Laravel)
- My service container setup — whether I register Graph as a singleton or resolve it per-request
- Whether I use Horizon or queue workers (affects credential lifetime)
- My GraphWarden proxy URL
- My proxy client_id and proxy client_secret
- Which Graph endpoints and scopes my app uses today
Reference: llms.txt
The two overrides
A direct-to-Graph PHP app configures two things: a client-credentials token request to Azure AD, and a GraphServiceClient that defaults to https://graph.microsoft.com. To route through GraphWarden you override both.
- Token acquisition — issue the
client_credentialsrequest against the proxy's token endpoint (/proxy/token) instead of Azure AD, and pass the Proxy Client ID and Proxy Client Secret in place of the Azure AD app credentials. The token you receive is a proxy JWT, not a Graph token; treat it as an opaque Bearer value. - Graph base URL — when constructing the
GraphServiceClient, pass a Guzzle HTTP client configured with the proxy as the base URI and callsetBaseUrlon the request adapter. Every SDK call then issuesGET {proxy-host}/v1.0/userswith the proxy JWT attached.
Install
composer require microsoft/microsoft-graph:^2 guzzlehttp/guzzle:^7
# guzzlehttp/guzzle is a transitive dependency — listed explicitly to anchor the version.
Minimal working sample (plain PHP)
The program below acquires a proxy JWT and lists display names for the first page of users. Both overrides are named explicitly so the shape of the change is obvious. Production apps should pull the proxy URL and credentials from environment variables (see the Laravel section) rather than hardcoding them.
<?php
// graphwarden-sample.php
declare(strict_types=1);
require __DIR__.'/vendor/autoload.php';
use GuzzleHttp\Client as GuzzleClient;
use Microsoft\Graph\GraphServiceClient;
use Microsoft\Kiota\Abstractions\Authentication\AccessTokenProvider;
use Microsoft\Kiota\Abstractions\Authentication\AllowedHostsValidator;
use Microsoft\Kiota\Abstractions\Authentication\BaseBearerTokenAuthenticationProvider;
use Microsoft\Kiota\Http\GuzzleRequestAdapter;
// 1. Configuration — in production, load these from $_ENV or a framework config layer.
$proxyBaseUrl = 'https://proxy.example.com'; // Issued when the proxy was deployed
$tokenEndpoint = $proxyBaseUrl.'/proxy/token'; // GraphWarden-issued proxy JWT endpoint
$proxyClientId = '00000000-0000-0000-0000-000000000000'; // Proxy Client ID from App Admin
$proxyClientSec = 'REDACTED'; // Proxy Client Secret from App Admin
// 2. Acquire a proxy JWT via client_credentials against the proxy token endpoint.
$tokenClient = new GuzzleClient();
$tokenResp = $tokenClient->post($tokenEndpoint, [
'form_params' => [
'grant_type' => 'client_credentials',
'client_id' => $proxyClientId,
'client_secret' => $proxyClientSec,
'scope' => 'https://graph.microsoft.com/.default',
],
]);
$proxyJwt = json_decode((string) $tokenResp->getBody(), true)['access_token'];
// 3. A minimal token provider that returns the proxy JWT from the request above.
// A production implementation refreshes the token before expiry.
$tokenProvider = new class($proxyJwt) implements AccessTokenProvider {
public function __construct(private string $token) {}
public function getAuthorizationTokenAsync(string $url, array $additionalAuthenticationContext = []): \Http\Promise\Promise {
return \Http\Promise\FulfilledPromise::create($this->token);
}
public function getAllowedHostsValidator(): AllowedHostsValidator {
return new AllowedHostsValidator();
}
};
// 4. Build the Graph Client with a request adapter whose Guzzle base_uri is the proxy.
$authProvider = new BaseBearerTokenAuthenticationProvider($tokenProvider);
$guzzle = new GuzzleClient(['base_uri' => $proxyBaseUrl]); // GraphWarden base URL override
$adapter = new GuzzleRequestAdapter($authProvider, null, null, $guzzle);
$adapter->setBaseUrl($proxyBaseUrl);
$graph = new GraphServiceClient($adapter);
// 5. Standard Graph SDK call — unchanged from a direct-Graph integration.
$page = $graph->users()->get(function ($config) {
$config->queryParameters->select = ['displayName'];
$config->queryParameters->top = 10;
})->wait();
foreach ($page->getValue() ?? [] as $user) {
echo $user->getDisplayName().PHP_EOL;
}
The important call is $graph->users()->get(...). It is identical to what you would write for a direct Graph integration — no request rewriting, no URL string building. The Guzzle base_uri and setBaseUrl call are what point the SDK at the proxy.
Laravel integration
In a Laravel application, bind the GraphServiceClient as a singleton in a service provider. Reading the proxy URL and credentials from .env via Laravel's config() helper keeps secrets out of the codebase.
<?php
// app/Providers/GraphWardenServiceProvider.php
declare(strict_types=1);
namespace App\Providers;
use GuzzleHttp\Client as GuzzleClient;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider;
use Microsoft\Graph\GraphServiceClient;
use Microsoft\Kiota\Abstractions\Authentication\AccessTokenProvider;
use Microsoft\Kiota\Abstractions\Authentication\AllowedHostsValidator;
use Microsoft\Kiota\Abstractions\Authentication\BaseBearerTokenAuthenticationProvider;
use Microsoft\Kiota\Http\GuzzleRequestAdapter;
class GraphWardenServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(GraphServiceClient::class, function () {
$proxyBaseUrl = config('services.graphwarden.url'); // GRAPHWARDEN_PROXY_URL
$proxyClientId = config('services.graphwarden.client_id'); // GRAPHWARDEN_CLIENT_ID
$proxyClientSec = config('services.graphwarden.secret'); // GRAPHWARDEN_CLIENT_SECRET
// Cache the proxy JWT for 55 minutes (tokens last 1 hour). Cached
// across queued jobs and web requests within one Laravel process.
$proxyJwt = Cache::remember('graphwarden:token', 3300, function () use (
$proxyBaseUrl, $proxyClientId, $proxyClientSec
) {
$resp = (new GuzzleClient())->post($proxyBaseUrl.'/proxy/token', [
'form_params' => [
'grant_type' => 'client_credentials',
'client_id' => $proxyClientId,
'client_secret' => $proxyClientSec,
'scope' => 'https://graph.microsoft.com/.default',
],
]);
return json_decode((string) $resp->getBody(), true)['access_token'];
});
$tokenProvider = new class($proxyJwt) implements AccessTokenProvider {
public function __construct(private string $token) {}
public function getAuthorizationTokenAsync(string $url, array $ctx = []): \Http\Promise\Promise {
return \Http\Promise\FulfilledPromise::create($this->token);
}
public function getAllowedHostsValidator(): AllowedHostsValidator {
return new AllowedHostsValidator();
}
};
$auth = new BaseBearerTokenAuthenticationProvider($tokenProvider);
$guzzle = new GuzzleClient(['base_uri' => $proxyBaseUrl]);
$adapter = new GuzzleRequestAdapter($auth, null, null, $guzzle);
$adapter->setBaseUrl($proxyBaseUrl);
return new GraphServiceClient($adapter);
});
}
}
Add matching keys to config/services.php:
// config/services.php
return [
// ... existing entries
'graphwarden' => [
'url' => env('GRAPHWARDEN_PROXY_URL'),
'client_id' => env('GRAPHWARDEN_CLIENT_ID'),
'secret' => env('GRAPHWARDEN_CLIENT_SECRET'),
],
];
Register the provider in bootstrap/providers.php (Laravel 11) or config/app.php (Laravel 10). Controllers and jobs then type-hint GraphServiceClient in their constructor and Laravel resolves the singleton.
Horizon and long-lived workers
If your app uses Laravel Horizon or a long-running queue worker, the singleton binding means one GraphServiceClient instance lives for the worker's lifetime. The Cache::remember wrapper above keeps repeated token fetches off the hot path; the first job in a process window pays the /proxy/token cost and the rest reuse the cached JWT.
Error handling
The PHP Graph SDK surfaces proxy failures as Microsoft\Kiota\Abstractions\ApiException instances with an HTTP status code. Catch and branch on outcome.
- 401 Unauthorized — the proxy JWT is expired, never issued, or the Proxy Client ID/Secret was rejected by
/proxy/token. Invalidate your cached token and request a fresh one. - 403 Forbidden — a rule returned a
blockverdict. The response body names the matched rule. Do not retry automatically; the caller or the App Identity lacks permission for this endpoint. - 502 Bad Gateway — Microsoft Graph returned a non-2xx upstream. Treat it as a transient Graph failure; back off and retry with jitter.
- 429 Too Many Requests — forwarded from Graph with
Retry-Afterintact. Honour the header exactly as you would against direct Graph.
Troubleshooting
The failure modes below cover the common onboarding surprises when wiring a PHP or Laravel app against GraphWarden for the first time.
401 from /proxy/token — invalid_client
The Proxy Client ID or Proxy Client Secret sent to /proxy/token does not match the hash stored for your App Identity. Confirm both values were copied from GraphWarden App Admin for the right App Identity. A common Laravel cause is .env caching: if you ran php artisan config:cache before updating the secret, run php artisan config:clear or re-run config:cache so the new value is picked up.
403 Forbidden on every Graph call
A rule in the ruleset bound to your App Identity is matching aggressively. The response body names the rule id. Review the rule's match.endpoint_pattern and conditions in App Admin, or use the What-If Simulator to replay a specific request and see which rule fires first.
502 Bad Gateway
Microsoft Graph returned a non-2xx upstream response and the proxy forwarded it as 502. Check Microsoft's Graph status page and the proxy's /health endpoint. Retry with exponential backoff and jitter; a transient Graph outage is the most common cause.
503 Service Unavailable on startup
The proxy could not read the real Graph credentials from Azure Key Vault. In on-prem deployments this usually means the Managed Identity is missing or the Key Vault access policy was not granted. The proxy operator, not your application, resolves this. Your app should retry with backoff — once the proxy recovers, token acquisition succeeds.
cURL error 60: SSL certificate problem
Guzzle uses libcurl which on older Windows PHP builds (pre PHP 8.1, WAMP, XAMPP) ships without a bundled CA store. Download Mozilla's cacert.pem, place it somewhere readable, and set curl.cainfo and openssl.cafile in php.ini to its absolute path. On modern Linux distributions and Homebrew PHP this works out of the box.
Composer autoload collisions with older Graph SDKs
The PHP Graph SDK went through a major rewrite between v1.x (legacy) and v2.x (Kiota-based). Mixing both in one composer.json produces fatal autoload errors because the PSR-4 roots overlap. Remove any microsoft/microsoft-graph-beta or microsoft/microsoft-graph:^1 entries, run composer remove followed by composer require microsoft/microsoft-graph:^2, then composer dump-autoload.
SDK calls go to graph.microsoft.com instead of the proxy
Both the Guzzle base_uri and GuzzleRequestAdapter::setBaseUrl must be set. If only the Guzzle base_uri is set, the adapter still composes absolute URIs against https://graph.microsoft.com. Set both — the sample above does this.
Last reviewed: