directorytree/ldaprecord-laravel
LdapRecord-Laravel integrates LDAP, OpenLDAP, and Active Directory with Laravel using Eloquent-style models, native authentication, user synchronization, multi-domain connections, and a testing toolkit, making it simpler to build applications that securely read and manage directory data.
Install and publish.
composer require directorytree/ldaprecord-laravel
php artisan vendor:publish --provider="LdapRecord\Laravel\LdapServiceProvider"
The service provider is auto-discovered; publishing gives you config/ldap.php. v4 is the current line (Laravel 11/12/13 compatible; Laravel 13 support landed in v3.4.3 and carries into v4).
Configure one connection first — nothing more.
// config/ldap.php
'connections' => [
'default' => [
'hosts' => [env('LDAP_HOST')], // comma-separated DCs allowed
'base_dn' => env('LDAP_BASE_DN'),
'username' => env('LDAP_USERNAME'), // service account (DN or user@domain)
'password' => env('LDAP_PASSWORD'),
'port' => env('LDAP_PORT', 389),
'use_ssl' => env('LDAP_SSL', false), // ldaps:// on 636
'use_tls' => env('LDAP_TLS', false), // STARTTLS on 389
'timeout' => 5,
],
],
Verify connectivity before writing any application code.
php artisan ldap:browse
This walks the tree from your base_dn using the bind account. If it fails, the problem is network/bind/base-DN — not your app. Fix that first; every downstream symptom (failed logins, empty imports) looks identical otherwise.
First use case: authenticate a corporate user. Wire the provider:
// config/auth.php
'providers' => [
'users' => [
'driver' => 'ldap',
'model' => LdapRecord\Models\ActiveDirectory\User::class, // or OpenLDAP\User
'database' => [
'model' => App\Models\User::class,
'sync_passwords' => true,
'sync_attributes' => [
'name' => 'cn',
'email' => 'mail',
'guid' => 'objectguid',
],
],
],
],
Then make your Eloquent model LDAP-aware:
use LdapRecord\Laravel\Auth\AuthenticatesWithLdap;
use LdapRecord\Laravel\Auth\LdapAuthenticatable;
class User extends Authenticatable implements LdapAuthenticatable
{
use AuthenticatesWithLdap;
}
Auth::attempt(['mail' => $email, 'password' => $pw]) now binds against the directory and creates/updates the local row. Drop the database block entirely for plain (stateless, no local row) LDAP auth — useful for admin consoles where you only need identity, not a user table.
Two auth modes, chosen per provider, not per app. Database-backed sync gives you local foreign keys, roles, and offline audit; plain mode gives you zero schema work. Because the provider is a normal Laravel provider, both can coexist — a web guard on the database driver and an ldap guard on the LDAP driver. Use this for reversible rollouts: ship LDAP login per tenant behind a flag while everyone else keeps password auth.
Model the directory the way you model the database. Subclass the base models and keep them in app/Models/Ldap/ (scaffold with php artisan ldap:make:model):
class User extends \LdapRecord\Models\ActiveDirectory\User
{
protected $connection = 'default'; // or a per-tenant connection
}
Relationships are first-class: hasMany(Group::class, 'member'), belongsToMany via memberof, hasOne for managers. Eager-load them (with('groups')) — a directory round-trip per row is orders of magnitude worse than an SQL N+1.
Group → role mapping belongs in an event listener, not a controller. Listen for the package's authentication events (Login, Failed, and the v3.2+ RulePassed / RuleFailed) or hook a Login listener that reads $user->groups() and syncs to your role table. Keeping it in a listener means the mapping also applies to imports and to queued flows.
Import is the operational workhorse. ldap:import reads sync_attributes from the matching auth provider, so imports and live logins write identical rows:
php artisan ldap:import users --filter="(objectclass=user)" --delete-missing
->withoutOverlapping()), not a manual ritual. Onboarding and offboarding then happen without a human.--delete-missing closes stale-account findings for SOC 2 / GDPR reviews; it respects soft deletes and, since v3.4.1, uses the model's deleted_at date format.foreach filter.Generate importers and rules instead of hand-rolling them. ldap:make:import gives you a swappable importer when default sync semantics don't fit (denormalizing department, deriving usernames). ldap:make:rule gives password-policy rules you can attach to a provider so RulePassed/RuleFailed fire and you get concrete answers for security questionnaires.
Password changes go through the model. Set password on the LDAP model and save(); the AD (unicodePwd) / OpenLDAP (userPassword) mutators handle encoding. Combine with sync_passwords if you need a valid local hash as a fallback path.
Test with the Directory Emulator, not a live directory. This is the single biggest estimate-saver — it removes the "we can only validate against the customer's AD in staging" bottleneck:
use LdapRecord\Laravel\Testing\DirectoryEmulator;
DirectoryEmulator::setup('default');
$user = LdapUser::create(['cn' => 'Jane', 'mail' => '[email protected]']);
$this->post('/login', ['email' => '[email protected]', 'password' => 'secret'])
->assertRedirect('/dashboard');
It emulates queries, relationships, memberof, chunking, and custom connections — enough for feature tests and a demo environment before a design-partner tenant exists.
Attribute casing and multi-value attributes. LDAP names are case-insensitive; PHP arrays are not. Use getFirstAttribute('mail') rather than $user->mail when the attribute may be multi-valued, and don't assume the casing you typed survives normalization.
Directory result caps. AD silently truncates at 1000 entries. If an import "works" but only picks up 1000 users, that's the server's MaxPageSize, not a bug in the package — confirm pagination is active and that you aren't hand-building a raw filter that bypasses the paginating query builder.
Failed logins are ambiguous until you read the diagnostic. AD returns different sub-codes for bad password (52e), expired password (532), and must-change (773). The package exposes the diagnostic and v4.0.3 fixed error-code identification from it — branch on that rather than treating every failure as "invalid credentials", or you'll send expired-password users into a support loop.
Logging is opt-in and worth turning on deliberately. Set the channel and level explicitly; v4.0.1 fixed logging that ignored the configured channel, and v3.1 added a configurable level. Note that the wildcard listeners for loggable events are only registered when logging is enabled (v3.3.4) — if you're debugging why you see nothing, check the flag before suspecting the connection.
The emulator is a fake, not a server. It won't enforce ACLs, schema constraints, or real password policy. Green tests do not prove the bind account has read rights across every OU. Keep one smoke test against a real tenant directory in your exit criteria.
Never call the directory from an accessor, a Blade template, or inside a loop. Reads are network calls with cold-cache latency. Cache aggressively (the config has a cache block), eager-load relationships, and push bulk work into ldap:import.
DN mutability. Renaming or moving an entry changes its DN, which is often the key you stored. Prefer objectguid (binary, converted by the package) as the stable identifier in your database, and treat DN as display data.
Version pinning matters more than usual here. Maintenance is concentrated around DirectoryTree and the dependent count is effectively nil relative to Laravel's ecosystem. Pin a minor version, read the changelog on bumps, budget upgrades as scheduled work, and keep an exit plan — which is realistic because the guard is a standard Laravel provider and swappable.
Where to extend rather than fork: custom importers (ldap:make:import), importer query scopes, password/validation rules (ldap:make:rule), invokable classes and closures as attribute handlers (v3.0.5), and event listeners on the auth and import events. Almost every "we need to change how sync works" request is an importer or a listener, not a patch.
How can I help you explore Laravel packages today?