directorytree/ldaprecord
LdapRecord is a full-featured LDAP ORM for PHP and Laravel. It offers ActiveRecord-style models, a fluent query builder, authentication, and multi-domain connection management, making OpenLDAP and Active Directory integration simple and expressive.
LdapRecord is the directory layer; in Laravel you almost always want the companion integration, which pulls in the core ORM:
composer require directorytree/ldaprecord-laravel
php artisan vendor:publish --provider="LdapRecord\Laravel\LdapServiceProvider"
Configure one connection in config/ldap.php (or .env), point it at a test DC — not production — and confirm a bind before writing any model code:
'connections' => [
'default' => [
'hosts' => [env('LDAP_HOST', 'ldap.acme.test')],
'base_dn' => env('LDAP_BASE_DN', 'dc=acme,dc=test'),
'username' => env('LDAP_USERNAME'),
'password' => env('LDAP_PASSWORD'),
'port' => 389,
'tls' => true,
'timeout' => 5,
],
],
Your first model. Keep it minimal — $objectClasses is what lets LdapRecord hydrate and search correctly:
namespace App\Ldap;
use LdapRecord\Models\Model;
class User extends Model
{
public static array $objectClasses = [
'top', 'person', 'organizationalPerson', 'user',
];
}
Then the first real use case — resolve a person by mail and read their DN:
$user = User::where('mail', '[email protected]')->first();
$user?->getDn(); // cn=Jane Doe,ou=Users,dc=acme,dc=test
$user?->getName(); // first RDN value: "Jane Doe"
Where to look first: ldaprecord.com/docs/core/v4 (the "Searching" and "Models" pages carry 80% of daily usage), then LdapRecord\Models\Model for the attribute/DN API and LdapRecord\Connection for config keys. Read the v4 upgrade guide once before you start — the v4 query builder filter semantics differ subtly from v3 (see v4.0.1/4.0.2 fixes), and it's cheaper to learn that now than mid-feature.
Model per object class, not per table. Define App\Ldap\User, App\Ldap\Group, App\Ldap\OrganizationalUnit. Use $connection to route to non-default DCs, $casts to normalise AD weirdness, and morphInto()/morphIntoOrFail() when an entry's objectclass determines the subclass (v4.1.1 fixed successful subclass conversions in morphIntoOrFail).
class User extends Model
{
public static array $objectClasses = ['top','person','organizationalPerson','user'];
protected $casts = [
'lastlogontimestamp' => 'datetime',
'pwdlastset' => 'datetime',
'accountcontrol' => 'integer',
];
}
Query builder as your authorization language. Prefer directory queries over a mirrored SQL org chart:
$isInGroup = User::find($dn)
->groups()
->where('cn', '=', 'VPN-Users')
->exists();
// Active Directory helper (note the {base} substitution fix in v3.8.2)
Group::whereMemberOf($user)->first();
For OU-scoped authorization use whereContains/whereStartsWith against the DN plus a global scope that pins the subtree — that keeps every query in the class from accidentally reaching the whole domain.
Relationships for membership. hasMany(Group::class, 'member') on the user side and the inverse on the group side. attach()/detach() writes LDAP directly. Treat detach with care — v4.1.1 fixed a bug where detaching relationships could delete populated groups; if you're on v3, don't automate detach.
Writes: create, rename, move. Set the DN (or use inside()) and save; {base} substitution now works during create and rename (v3.3.0), which makes provisioning code portable across environments:
$user = (new User)->inside('ou=Users,{base}');
$user->cn = 'Jane Doe';
$user->mail = '[email protected]';
$user->save();
$user->rename('cn=Jane Smith'); // or renameAndGetDn() when you need the new DN
Self-service passwords are a first-class path as of v4.1.0 — User::changePassword($old, $new) for OpenLDAP (including Argon2 hashes). The prerequisite is a TLS/SSL-bound connection; allow_insecure_password_changes exists to relax that, and it should be a documented, reviewed decision rather than an env default.
Multiple connections and failover. Give each domain/forest its own connection entry and resolve models with $model->on('corp')->... rather than swapping global state. Keep hosts as an array so failover works without a code change.
Testing without a DC. DirectoryFake::setup() in setUp(), DirectoryFake::tearDown() in tearDown() (the container flush landed in v3.6.4), then assert on the interface calls your code makes. This is the difference between "identity features ship continuously" and "identity features ship when someone gets VPN access to the lab."
Observability. Listen to model events (Retrieved, Saving, Created, Deleting…), the Laravel auth events (Authenticated, Failed — which carries a BindException since v3.6.1), and log query failures with DetailedError's non-nullable $errorCode/$errorMessage (v3.2.3).
$user->mail returns ['[email protected]']. Use $user->getFirstAttribute('mail') or casts; writing a scalar string into a multi-valued attribute is a classic silent footgun.{base} resolves to the connection's base_dn at query time — great for env portability, dangerous if you cache a resolved DN string anywhere.paginate() and chunk() issue paged results; v4.1.1 fixed leftover paging controls when chunking stops early and reset stale pagination state. If you break out of a chunk() loop, verify the next query on the same connection isn't still paging.->whereIn() with an empty array returns zero results (fixed in v3.8.5) — it does not degrade to "no filter." Guard the empty case yourself; it's the most common source of "the list went blank" bugs.refresh() returns false for missing entries instead of throwing (v4.1.1). Check the return value.orderBy() uses an LDAP sort control; OpenLDAP generally doesn't support it. Sort in PHP for OpenLDAP, and remember chunking overrides the query limit to 0.secure flag is reset on close() (v3.8.3); if you're reusing connections in long-running workers, close and re-init after network errors rather than assuming the upgrade survived.Ldap::DEBUG_* constants (v3.7.2) map to LDAP_OPT_DEBUG_LEVEL bits — set them via the connection options in local only. For real diagnosis, read the DetailedError and the raw filter from the builder (->toRawFilter()) before guessing at schema.objectGUID and objectSid serialise/unserialise into binary; v3.5.1 fixed UTF-8 and ordering issues there. Don't put raw GUIDs in user-facing URLs without a stable string cast.orFilter/andFilter/orWhere call sites — those were the behavioural churn points in 4.0.x.Timestamp/cast classes, the protocol connection option (v3.7.0), and model morphInto for polymorphic object classes. Those four cover most "we need it to behave differently" requests without forking.How can I help you explore Laravel packages today?