sroutier/eloquent-ldap
Bring Eloquent-style syntax to LDAP in Laravel: query and authenticate against directory servers like Active Directory or OpenLDAP using familiar model methods, so user lookups feel like any other database query.
Install and register
composer require sroutier/eloquent-ldap
php artisan vendor:publish --provider="Sroutier\EloquentLDAP\EloquentLDAPServiceProvider"
If you're not on package auto-discovery, add the service provider to config/app.php. Before anything else, check the composer.json constraints in vendor/sroutier/eloquent-ldap against your Laravel version — this package is small (20 stars, 0 public dependents) and was built for the Laravel 5.x era. Confirm it installs and boots before writing code against it.
Configure config/ldap.php
The first place to look after publishing is the config file — this is where 90% of your day-to-day debugging will happen. Fill in:
host / port — use 636 or STARTTLS on 389, never send credentials in cleartextbase_dn and the user search filter (e.g. (&(objectClass=user)(sAMAccountName=...)) for AD, uid for OpenLDAP)domain / account suffix for AD ([email protected] vs CORP\user)Prepare the users table
The package mirrors LDAP users into your local users table. Ensure password is nullable (LDAP users have no local hash), and that your identifier column (often username or email) is unique and matches whatever attribute LDAP returns. Add domain/source columns if you want to distinguish local vs directory users later.
Point auth at the package
Easiest path: use the package's model in config/auth.php:
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => Sroutier\EloquentLDAP\Models\User::class, // or your App\User with the trait
],
],
First use case — and the one that justifies the package
Keep a break-glass local admin account that works when the directory is unreachable, and let everyone else authenticate against AD. In tinker:
Auth::attempt(['username' => 'jdoe', 'password' => 'secret']);
This should hit the local DB first, miss, bind to LDAP, and (depending on config) create the local mirror row. Verify the row is created with a null/blank password and the right email.
Local-first fallback is the whole feature — lean into it
Don't fight the order. The local users table is your operational safety net:
Hash::make() passwords; LDAP accounts keep password = null.Just-in-time provisioning with role mapping
On each successful LDAP bind, the package creates/updates the local user. Extend that in your model (or an event listener on user retrieval) to translate memberOf groups into local roles:
// In your User model or a login listener
protected function syncRolesFromLdap(array $attributes)
{
$groups = $attributes['memberOf'] ?? [];
$this->roles()->sync(
collect($groups)->map(fn ($dn) => $this->roleForGroup($dn))->filter()->all()
);
}
Keep a mapping array of LDAP group DN → local role name in a config file. This is the single most valuable thing you'll build on top of the package, because raw LDAP group DNs are unusable in Gate/can: checks.
Separate guards for different populations
If some users are local-only (service accounts, contractors on a separate auth) and others go through LDAP, define a second provider/guard:
'guards' => [
'web' => ['driver' => 'session', 'provider' => 'ldap_users'],
'local' => ['driver' => 'session', 'provider' => 'users'],
],
Then auth:web for the SSO-facing routes and auth:local for internal tooling.
Session/API split
Auth::attempt() is for web sessions. For API tokens, authenticate first, then issue a token:
if (Auth::attempt($credentials)) {
$token = auth()->user()->createToken('api')->plainTextToken;
}
Don't try to use the LDAP fallback as a token issuer — it's a credential check, nothing more.
Testing
Don't hit a real directory in tests. Either:
LdapAuthenticator) with a fake driver, orAt minimum, test both branches: local-hit short-circuits LDAP, and LDAP-hit provisions the row. A test that asserts "LDAP unreachable → local admin still logs in" will save you at 2am.
GPL-3.0 is a real consideration, not a footnote. This package is GPL-3.0 while Laravel apps are commonly proprietary. If you're shipping a closed-source product, get a legal read before bundling it — GPL copyleft can be interpreted to apply to the combined work. This is the single biggest reason to consider reimplementing the thin fallback logic yourself (it's genuinely ~200 lines around Auth::attempt) rather than depending on the package.
Maintenance risk is high. 20 stars, 0 dependents, built several Laravel generations ago. Expect:
illuminate/* version conflicts on Laravel 9/10/11Mitigation: pin the exact version, fork it into your own repo and maintain it, or budget a day to rewrite the fallback inside your app. Given how thin the functionality is, a fork is often cheaper than fighting compatibility.
LDAP injection. The username from your form goes into a search filter. Verify the package escapes special characters (*, (, ), \, NUL) before interpolation. If it doesn't, escape with ldap_escape($username, '', LDAP_ESCAPE_FILTER) in your own layer. A * in the username field should never widen the search.
Anonymous / empty bind false positives. Some LDAP servers accept an anonymous bind as "success." If you have a blank service-account password in config/ldap.php, you may see every login succeed. Validate that the bind DN is set and the bind returns an actual user entry, not just a successful connection.
AD specifics:
sAMAccountName vs userPrincipalName — pick deliberately; they differ for migrated accounts.LDAP_OPT_REFERRALS false) or searches hang at the domain root.memberOf — you'll need LDAP_MATCHING_RULE_IN_CHAIN (1.2.840.113556.1.4.1941) for full membership resolution.userAccountControl (bit 0x2 = disabled) in the search result before treating the login as valid.Password resets will break for LDAP users. Laravel's default ForgotPassword flow hashes into the local DB, which does nothing for a user whose password lives in AD. Disable password-reset routes for directory users, or integrate ldappasswd / AD's unicodePwd (requires LDAPS). The package does not do password sync in either direction.
"Wrong password" and "LDAP is down" return the same false. Log the distinction yourself — wrap the attempt or hook the bind failure — otherwise you'll spend hours debugging a "bad credentials" report that's actually a firewall change. Set a short LDAP_OPT_NETWORK_TIMEOUT so an unreachable directory doesn't hang requests for 30+ seconds.
Connection cost. Each Auth::attempt() on the LDAP path is a full connect + bind + search. On an internal app that's fine; on a high-traffic login endpoint, cache group memberships per user (5–15 min TTL) rather than re-querying memberOf every request.
Extension points worth using: the user model (for attribute mapping and role sync), the config file (for filters and attribute names), and the service provider binding (for swapping the connection/lookup class). Avoid patching vendor/ directly — fork or subclass.
How can I help you explore Laravel packages today?