overtrue/laravel-keycloak-admin
Laravel wrapper for the Keycloak Admin REST API. Manage Keycloak users, roles, groups, and clients from your Laravel app with a fluent, configurable client—ideal for syncing accounts, assigning permissions, and automating identity workflows.
Install and publish config
composer require overtrue/laravel-keycloak-admin
php artisan vendor:publish --provider="Overtrue\LaravelKeycloakAdmin\KeycloakAdminServiceProvider"
Minimum config (config/keycloak.php, all env-driven):
KEYCLOAK_AUTH_SERVER_URL=https://sso.example.com
KEYCLOAK_REALM=my-realm
KEYCLOAK_CLIENT_ID=laravel-admin
KEYCLOAK_CLIENT_SECRET=xxxxxxxx
The package authenticates as a confidential client via the client_credentials grant, so the service account behind laravel-admin must be granted the relevant realm-management roles (view-users, manage-users, view-realm, manage-clients, …). This is the #1 source of "it doesn't work" — the package will happily return 401/403 and developers waste hours looking at Laravel config when the problem is Keycloak role mapping.
Where to look first
config/keycloak.php after publishing — realm, auth server URL, and the cache block.Overtrue\LaravelKeycloakAdmin\Facades\KeycloakAdmin — this is the entry point, not the whole API.overtrue/keycloak) — it exposes the fluent resource API (users(), groups(), roles(), clients(), realms()). The Laravel package is a thin container binding + token/cache layer; almost all real documentation lives in the SDK README.First use case — provision a user from an admin panel and assign a realm role:
use Overtrue\LaravelKeycloakAdmin\Facades\KeycloakAdmin;
$user = KeycloakAdmin::users()->create([
'username' => $request->email,
'email' => $request->email,
'firstName' => $request->first_name,
'lastName' => $request->last_name,
'enabled' => true,
'emailVerified' => false,
'credentials' => [[
'type' => 'password',
'value' => Str::password(24),
'temporary' => true,
]],
]);
KeycloakAdmin::users()->roles()->attach($user->getId(), [
['id' => $roleId, 'name' => 'member'],
]);
If that round-trips, the service account is configured correctly and you can build on it.
Version note (1.0.3): this release is a maintenance drop — Laravel 13 support plus Dependabot dev-dependency bumps. No API or config changes from 1.0.2, so anything written against 1.0.x keeps working. If you're on Laravel 13 this is the version you need; on Laravel 10–12 there's no functional reason to upgrade urgently.
Wrap the facade, don't sprinkle it. The facade is a singleton bound in the container and its API is broad and untyped at the call site. Keep it behind one or two small application services (KeycloakUserService, KeycloakRoleService) so that:
final class KeycloakUserService
{
public function findById(string $id): ?UserRepresentation
{
try {
return KeycloakAdmin::users()->get($id);
} catch (NotFoundException) {
return null;
}
}
}
Sync, don't dual-write. The durable pattern is: local users table is the source of truth for app data; keycloak is the identity provider. Store keycloak_id on your user model, write locally first inside a transaction, then push to Keycloak in a queued job. If the Keycloak call fails, the job retries and the local record stays consistent. Doing both writes inline gives you split-brain on any network blip.
Queue the write operations. users()->create() and role attachments are 200–800ms round-trips; a bulk import of 500 users will time out a web request. Use ShouldBeQueued jobs with $tries = 3 and backoff() — Keycloak occasionally returns 502 behind a load balancer and a retry fixes it.
Login-side integration. For authentication itself, prefer a dedicated OIDC/Socialite driver. Use this package for the admin surface: user provisioning, role/group management, session termination (users()->logout($id)), required-action resets (users()->executeActionsEmail(...)), and back-office tooling. Mixing admin-API calls into the hot login path adds a service-account dependency to every login.
Group-based authorization. Model entitlements as Keycloak groups with realm roles mapped to them, then resolve a user's effective roles server-side and cache them in your own cache keyed by user ID. Reading users()->groups()->all($id) on every request is a guaranteed latency problem.
Cache-aware token handling. The package caches the access token in the configured Laravel cache store. In multi-node deployments that means the cache store must be shared (redis, database) — array or file on a per-node basis causes every node to re-authenticate, and Keycloak's token endpoint will start rate-limiting or logging failed logins under load.
Testing. Http::fake() will not intercept these calls — the SDK uses its own PSR-18 client, bypassing Laravel's HTTP layer. Two workable approaches:
// 1. Bind a fake client into the container
$this->mock(Keycloak::class, function ($mock) {
$mock->shouldReceive('users->get')->with('abc')->andReturn($fakeUser);
});
// 2. Point the config at a local stub server in tests
config(['keycloak.auth_server_url' => 'http://localhost:8081']);
Option 1 is faster and is what most teams end up doing.
409 Conflict is the normal "user already exists" path. users()->create() throws rather than returning null. Catch the conflict, then look the user up by username (users()->search(['username' => $x])->first()) and reconcile. Don't pre-check with a search then create — that's a race.
Updates are full replacements. Keycloak's PUT /users/{id} expects a complete UserRepresentation. Sending ['firstName' => 'X'] alone will null out email, enabled, etc. Always get() → mutate → update(), and be aware that this is last-write-wins across concurrent editors.
Pagination defaults are deceptive. users()->all() returns the first page only (Keycloak defaults to a small max). For anything user-facing iterate explicitly:
$first = 0;
do {
$page = KeycloakAdmin::users()->search(['first' => $first, 'max' => 100]);
// ... process
$first += 100;
} while ($page->count() === 100);
Search syntax is Keycloak's, not SQL's. users()->search(['username' => 'jo']) is a prefix/infix match, not exact, and email requires exact=true to avoid surprises. Read the GET /users query params section before trusting a filter.
Attributes are array<string, array<string>>. 'attributes' => ['tenant' => 'acme'] silently misbehaves; it must be ['tenant' => ['acme']]. This is the most common silent data-loss bug with the package.
No transactions, no rollback. Creating a user then attaching roles is two calls. If the second fails you have a half-provisioned account. Make the provisioning job idempotent (check before attach, or use roles()->all() then diff) and safe to re-run.
Realms are per-config, not per-call. Everything goes to config('keycloak.realm'). Multi-tenant setups need either a per-tenant config swap (config()->set() + forget the resolved singleton) or a second container binding per realm. The singleton means a naive config()->set() mid-request will reuse a client built with the old realm — resolve/bind explicitly per tenant.
Timeouts and retries belong to you. There's no built-in retry policy. Wrap calls that touch the admin API in retry(3, fn () => ..., 200) with jitter, and set a sane HTTP timeout. A hung Keycloak should degrade your admin UI, not hang a queue worker for 60s.
Debugging. When you get an opaque failure, drop down to the raw response: catch the exception, log getCode(), getMessage(), and the response body (Keycloak returns a useful errorMessage field). Enabling Guzzle/SDK request logging at debug level in a local environment is the fastest way to see the exact URL and payload — most "403" issues are visible immediately as a missing role on the service account.
Long-running processes. Under Octane or long queue workers, the facade resolves once per process. Tokens are refreshed via the cache layer, but if you use per-tenant config swapping, remember to app()->forgetInstance(...) between jobs so a stale realm/client doesn't leak into the next tenant's work.
Extension points are thin but sufficient. You can publish and edit the config, rebind the underlying client in a service provider to inject a custom HTTP client (useful for proxies, mTLS, or correlation-ID middleware), and swap the cache store via the cache block. Beyond that the package is deliberately a passthrough — plan on your own service layer rather than expecting hooks.
Keep an eye on the SDK, not the wrapper. Because this package is a thin adapter, meaningful behaviour changes (new resource methods, signature changes) arrive via overtrue/keycloak minor bumps. Pin the SDK in composer.json if you depend on specific method signatures, and read its changelog on upgrade — the Laravel package's own release notes (like this 1.0.3) often won't mention them.
How can I help you explore Laravel packages today?