Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Ldap Laravel Package

symfony/ldap

LDAP client for PHP built on the ldap extension, providing connection, querying, and entry management since Symfony 3.1. Stable and maintained within the Symfony ecosystem.

View on GitHub
Deep Wiki
Context7

Getting Started

The component is standalone Symfony — it does not require the Symfony framework and drops cleanly into Laravel. It is a thin, typed wrapper over ext-ldap: Ldap → AdapterInterface → Query/Entry/EntryManager.

1. Prerequisites. PHP's ldap extension is a hard dependency. On the official Docker images:

RUN apt-get update && apt-get install -y libldap2-dev \
 && docker-php-ext-install ldap

2. Install a version that matches your Laravel Symfony pin. Laravel pulls symfony/* transitive packages; let Composer resolve, or force the branch compatible with your PHP:

composer require symfony/ldap:^7.4   # PHP 8.2/8.3 (Laravel 11/12)
# Symfony 8.x requires PHP >= 8.4

3. Config file. Keep DSNs and service-account credentials out of code:

// config/ldap.php
return [
    'default' => env('LDAP_CONNECTION', 'main'),
    'connections' => [
        'main' => [
            'host' => env('LDAP_HOST', 'ldap.internal'),
            'port' => (int) env('LDAP_PORT', 389),
            'encryption' => env('LDAP_ENCRYPTION', 'none'), // none|ssl|tls
            'base_dn' => env('LDAP_BASE_DN', 'dc=example,dc=com'),
            'username' => env('LDAP_USERNAME'),   // service account
            'password' => env('LDAP_PASSWORD'),
            'options' => [
                'protocol_version' => 3,
                'referrals' => false,
                'network_timeout' => 5,
            ],
        ],
    ],
];

4. Bind it in a service provider. Use scoped(), not singleton() — a single LDAP handle must not leak across requests in Octane or across jobs in a queue worker:

$this->app->scoped(LdapManager::class, fn ($app) => new LdapManager($app['config']['ldap']));
$this->app->scoped(LdapInterface::class, fn ($app) => $app->make(LdapManager::class)->connection());

5. First use case — authenticate a user. The canonical flow is: search as the service account to find the DN, then bind as that DN with the submitted password.

$ldap = Ldap::create('ext_ldap', $config);
$ldap->bind($config['username'], $config['password']); // service account

$filter = sprintf('(&(objectClass=person)(uid=%s))',
    ldap_escape($username, '', LDAP_ESCAPE_FILTER));

$entries = $ldap->query($config['base_dn'], $filter)->execute();

if (0 === count($entries)) {
    return false;
}

$dn = $entries[0]->getDn();

// Rebinding the same adapter replaces the service-account session — see Gotchas.
$ldap->bind($dn, $password); // throws InvalidCredentialsException on failure

Where to look first: the component docs at symfony.com/doc/current/components/ldap for the Ldap::create() option matrix, and the Symfony\Component\Ldap\Adapter\ExtLdap namespace in vendor/ when behaviour surprises you. The README points at the Symfony monorepo for issues; there is no separate Laravel wrapper.

Implementation Patterns

Connection manager for multiple directories. Multi-tenant or hybrid AD + OpenLDAP setups need one Ldap instance per directory, built lazily and cached per request:

final class LdapManager
{
    private array $resolved = [];

    public function __construct(private array $config) {}

    public function connection(?string $name = null): LdapInterface
    {
        $name ??= $this->config['default'];

        return $this->resolved[$name] ??= Ldap::create('ext_ldap', $this->config['connections'][$name]);
    }

    public function forget(): void { $this->resolved = []; }
}

Bind the service account inside connection() so callers never see credentials, and use a second named connection for user authentication so the search session is never invalidated.

Repository layer, not raw Ldap in controllers. Wrap every query so filter escaping, DN scoping, and error translation live in one place:

final class LdapUserRepository
{
    public function __construct(
        private LdapInterface $read,          // service-account connection
        private string $baseDn,
    ) {}

    public function findByLogin(string $login): ?Entry
    {
        $filter = sprintf('(&(objectClass=user)(sAMAccountName=%s))',
            ldap_escape($login, '', LDAP_ESCAPE_FILTER));

        $results = $this->read
            ->query($this->baseDn, $filter, ['scope' => Query::SCOPE_SUB])
            ->execute();

        foreach ($results as $entry) {
            return $entry; // first match
        }

        return null;
    }
}

Custom guard via a user provider. Laravel's Auth::attempt() works if you register a provider driver. Retrieve by credentials with the read connection; validate credentials by binding on the auth connection:

Auth::provider('ldap', fn ($app, array $config) => new LdapUserProvider(
    $app->make(LdapUserRepository::class),
    $app->make(LdapManager::class)->connection('auth'),
));

In validateCredentials() do $this->auth->bind($user->getAuthIdentifier(), $credentials['password']) and catch InvalidCredentialsException. In retrieveById(), treat the DN as the identifier so Auth::loginUsingId() and session rehydration work; in updateRememberToken(), either no-op or store the token in a dedicated attribute — LDAP has no remember_token column, so most teams simply disable "remember me" for LDAP users.

Hybrid local profile + LDAP identity. Keep an Eloquent users row keyed on an immutable directory identifier (objectGUID decoded to a canonical hex string, or the DN) and sync display name, mail, and group membership on login. Map directory groups to Spatie/Laravel roles:

$groups = $entry->getAttribute('memberOf') ?? [];
foreach ($groups as $dn) {
    if (preg_match('/^CN=([^,]+)/i', $dn, $m) && isset($map[$m[1]])) {
        $user->assignRole($map[$m[1]]);
    }
}

For nested AD groups, memberOf is only the direct parents. Query with the chaining rule instead:

$filter = sprintf('(&(objectClass=group)(member:1.2.840.113556.1.4.1941:=%s))',
    ldap_escape($userDn, '', LDAP_ESCAPE_DN));

Writes and provisioning go through the entry manager; use it for onboarding scripts and attribute corrections, not for hot paths:

$ldap->getEntryManager()->add($entry);       // $entry = new Entry($dn, ['objectClass' => [...], ...])
$ldap->getEntryManager()->update($entry);
$ldap->getEntryManager()->remove($dn);

Health check command. A php artisan ldap:check that binds, runs a SCOPE_BASE query on the base DN, and prints whoami() (added in 7.2) pays for itself the first time a certificate rotates.

Testing. Never mock the static factory. Mock AdapterInterface and inject it into new Ldap($adapter), or swap the container binding with a hand-written fake implementing LdapInterface. Keep one integration suite in CI against a bitnami/openldap or AD LDS container; fixture DNs there catch escaping and DN-syntax bugs that mocks never will.

Gotchas and Tips

  • Empty passwords can authenticate. Many servers treat a bind with an empty password as an unauthenticated bind and return success. Reject '' and null passwords before calling bind(). This is the single most common LDAP auth vulnerability.
  • One handle, one bind. A Ldap instance is stateful; rebinding replaces the session. Do not interleave a service-account search with a user bind on the same adapter. Separate connections for read vs. authenticate.
  • getAttribute() always returns an array (or null). Single-valued attributes need [0] ?? null. Attribute names are effectively lowercase from ext-ldap; getAttribute('sAMAccountName') may miss — normalize to lowercase and prefer hasAttribute() guards.
  • Escape everything that reaches a filter or DN. ldap_escape($v, '', LDAP_ESCAPE_FILTER) for filters, LDAP_ESCAPE_DN for DNs. User input like * or admin)(|(uid=* otherwise turns into a directory-wide wildcard or filter injection.
  • No attribute selection. Symfony's Query fetches all attributes. Groups with thousands of member values will blow up memory and wire time. If you need attribute-limited reads, drop to native ldap_search() with an attribute list for that one query.
  • No built-in paging beyond page_size. Pass ['page_size' => 500] when the server supports the paged-results control; iterate results lazily rather than toArray(). AD additionally caps multi-valued attributes at 1500 entries and returns member;range=0-1499 — Symfony does not transparently continue range retrieval, so large groups need manual range logic.
  • Binary attributes. objectGUID and objectSID arrive as raw bytes. Convert with bin2hex()/manual little-endian parsing before storing. Do not compare raw bytes to a string column.
  • sizeLimit was removed from AbstractQuery in Symfony 8.0. If you upgrade from 6.4/7.x, replace it with an explicit array_slice() or the server-side page_size. Also align the component version with Laravel's Symfony pin to avoid a Composer resolution fight in symfony/options-resolver.
  • TLS vs. LDAPS. 'encryption' => 'tls' performs StartTLS after connecting on 389; 'ssl' (or an ldaps:// connection_string) connects on 636 from the start. Verify CA trust via LDAP_OPT_X_TLS_* config in php.ini or pass options — an untrusted certificate fails with a cryptic Can't contact LDAP server.
  • Error handling: expect Symfony\Component\Ldap\Exception\ConnectionException, InvalidCredentialsException, and the base LdapException (whose code is ldap_errno). Catch InvalidCredentialsException for auth failures and let ConnectionException bubble to a 503-style page — the difference between "wrong password" and "directory down" matters.
  • Reset between units of work. The adapter was made resettable in 6.4.36 / 7.4.8 / 8.0.8 precisely for long-running processes. With Octane, queues, or php artisan horizon, use scoped() bindings and call your manager's forget() (or app()->forgetInstance()) when a job finishes; stale handles surface as mysterious Protocol error or timeout failures on the second job.
  • Timeouts: network_timeout plus PHP's default_socket_timeout (cast to int in recent patches) govern connect/read limits. Set both — a hung directory otherwise stalls the whole queue worker.
  • Nothing is logged. There is no logger in the component (the Symfony FrameworkBundle wires one via debug). Log DNs, filters, and durations in your repository; never log passwords or full entry payloads into an audit trail.
  • No connection pooling, no transactions, no identity map. Directory writes are immediate and non-atomic; AD replicas are eventually consistent, so a just-created user may not be findable on another DC for seconds. Retry reads with a short backoff after provisioning.
  • SASL bind and whoami() are available from 7.2 for Kerberos/GSSAPI environments, but depend on the PHP build and server config — verify in the target environment before designing around them.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core