Skip to content
Precious Lyson edited this page Nov 13, 2026 · 2 revisions

The LDAP component is a fully-featured wrapper around PHP's native ldap_* extension. It provides:

  • Fluent query builder — chainable, expressive queries
  • Automatic connection management — lazy connection with failover across multiple hosts
  • Secure authentication — LDAP bind, TLS, and SSL support
  • Entry CRUD — create, read, update, delete with attribute-level operations
  • Batch modifications — apply multiple LDAP operations atomically
  • Recursive group resolution — including nested group membership
  • Query caching — reduce LDAP traffic
  • Pagination — handle large directories with paged results control
  • User provider — plug into your auth system with local model synchronization
  • Events — hook into every stage of the lifecycle

Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                          Manager                            │
│  - Connection pool (named connections)                      │
│  - EntryManager delegation                                  │
│  - __call() proxy to default connection                    │
└───────────────┬─────────────────────────────┬───────────────┘
                │                             │
                ▼                             ▼
        ┌───────────────┐             ┌───────────────┐
        │  Connection   │             │ EntryManager  │
        │  - connect()  │             │  - create()   │
        │  - bind()     │             │  - read()     │
        │  - query()    │             │  - update()   │
        └───────┬───────┘             │  - delete()   │
                │                     └───────┬───────┘
                ▼                             │
        ┌───────────────┐                     │
        │    Builder    │◄────────────────────┘
        │  - where()    │
        │  - orWhere()  │
        │  - get()      │
        │  - first()    │
        └───────┬───────┘
                │
                ▼
        ┌───────────────┐
        │     Entry     │
        │  - getDn()    │
        │  - getAttr()  │
        │  - inGroup()  │
        └───────────────┘

Requirements

  • PHP 8.4+
  • PHP LDAP extension (ext-ldap) — required
  • OpenLDAP or Active Directory server
  • ext-ldap with support for ldap_modify_batch (PHP 7.2+, for batch operations)
  • ext-ldap with LDAP_CONTROL_PAGEDRESULTS (PHP 7.3+, for pagination)

Verify your environment:

php -m | grep ldap
php -r "var_dump(function_exists('ldap_modify_batch'));"

Installation

The LDAP component is part of the core framework. If installing standalone, ensure the namespace is autoloaded:

{
    "autoload": {
        "psr-4": {
            "Mlangeni\\Machinjiri\\Core\\Components\\LDAP\\": "src/Components/LDAP/"
        }
    }
}

Configuration

The component accepts a configuration array. A typical configuration looks like this:

// config/ldap.php
return [
    'default' => env('LDAP_CONNECTION', 'default'),

    'connections' => [
        'default' => [
            'hosts'    => ['ldap.example.com'],
            'port'     => 389,
            'base_dn'  => 'dc=example,dc=com',
            'username' => 'cn=admin,dc=example,dc=com',
            'password' => 'secret',
            'use_ssl'  => false,
            'use_tls'  => true,
            'auto_bind'=> true,
            'options'  => [
                'LDAP_OPT_PROTOCOL_VERSION' => 3,
                'LDAP_OPT_REFERRALS'        => 0,
                'LDAP_OPT_NETWORK_TIMEOUT'  => 5,
            ],
        ],

        'ad' => [
            'hosts'    => ['ad1.corp.local', 'ad2.corp.local'],
            'port'     => 636,
            'base_dn'  => 'dc=corp,dc=local',
            'username' => 'svc-ldap@corp.local',
            'password' => env('AD_PASSWORD'),
            'use_ssl'  => true,
            'use_tls'  => false,
            'auto_bind'=> true,
            'options'  => [
                'LDAP_OPT_PROTOCOL_VERSION' => 3,
                'LDAP_OPT_REFERRALS'        => 0,
            ],
        ],
    ],
];

Configuration Options

Key Type Default Description
hosts array [] List of LDAP servers. Connection attempts cascade through hosts until one succeeds.
port int 389 Port. Use 636 for ldaps://.
base_dn string '' Base DN for all searches.
username string null Bind DN. If set with auto_bind enabled, the connection will bind on connect.
password string null Bind password.
use_ssl bool false Use ldaps:// scheme.
use_tls bool false Start TLS (ldap_start_tls) after connecting over ldap://.
auto_bind bool false Automatically bind on connect if credentials are present.
options array [] LDAP options. Supported keys: LDAP_OPT_PROTOCOL_VERSION, LDAP_OPT_REFERRALS, LDAP_OPT_NETWORK_TIMEOUT.

Note: use_ssl and use_tls are mutually exclusive in practice. Use use_ssl for encrypted-from-start connections (port 636) and use_tls for STARTTLS upgrade (port 389).


Quick Start

use Mlangeni\Machinjiri\Core\Components\LDAP\Manager;

// Build manager from config
$ldap = new Manager(config('ldap'));

// Find users in the "admins" group
$admins = $ldap->query()
    ->baseDn('ou=people,dc=example,dc=com')
    ->where('objectClass', '=', 'inetOrgPerson')
    ->whereMemberOf('cn=admins,ou=groups,dc=example,dc=com')
    ->select(['cn', 'mail', 'uid'])
    ->get();

foreach ($admins as $entry) {
    echo $entry->getAttribute('cn') . ' <' . $entry->getAttribute('mail') . ">\n";
}

Beginner Guide

Connecting to LDAP

The Manager handles the connection lifecycle for you. Connections are lazy — the first time you query, it connects.

use Mlangeni\Machinjiri\Core\Components\LDAP\Manager;

$ldap = new Manager($config);

// Manually force a connection
$connection = $ldap->connection();
$connection->connect();

// Or just use any query — the connection opens automatically
$users = $ldap->query()->get();

You can check the connection state:

$connection->isConnected();  // true if a link exists
$connection->isBound();      // true if a bind succeeded

To close a connection:

$connection->close();

The Connection class implements __destruct() and will close the LDAP resource when it goes out of scope, but explicit close() is preferred in long-running processes.

Querying Entries

The query builder returns Entry objects. Use get() for a collection, first() for a single result.

use Mlangeni\Machinjiri\Core\Components\LDAP\Manager;

$ldap = new Manager($config);

// All entries in a base DN
$entries = $ldap->query()
    ->baseDn('ou=people,dc=example,dc=com')
    ->get();

// A single entry
$entry = $ldap->query()
    ->baseDn('ou=people,dc=example,dc=com')
    ->where('uid', '=', 'jdoe')
    ->first();

if ($entry) {
    echo $entry->getAttribute('cn');
}

Scope

LDAP supports three search scopes, mapped to ldap_search, ldap_read, ldap_list:

Constant Value Description
LDAP_SCOPE_BASE 0 Read a single entry (the base DN itself).
LDAP_SCOPE_ONELEVEL 1 Search only direct children of the base.
LDAP_SCOPE_SUBTREE 2 Search the entire subtree (default).
$ldap->query()
    ->baseDn('ou=people,dc=example,dc=com')
    ->scope(LDAP_SCOPE_ONELEVEL)
    ->get();

Tip: get() always calls ldap_search() internally, which is subtree-scoped at the C level. To restrict scope, you may need to add a filter or use base_dn more strategically. For single-entry lookups, prefer $ldap->read($dn).

Reading a Single Entry

EntryManager::read() performs an ldap_read() — efficient for DN lookups.

$entry = $ldap->read('uid=jdoe,ou=people,dc=example,dc=com');

if ($entry) {
    echo $entry->getDn();                    // uid=jdoe,ou=people,dc=example,dc=com
    echo $entry->getAttribute('mail');       // jdoe@example.com
    $mails = $entry->getAll('mail');         // array of all mail values
    $all   = $entry->getAttributes();        // full attribute array
}

You can restrict attributes for performance:

$entry = $ldap->read('uid=jdoe,ou=people,dc=example,dc=com', ['cn', 'mail']);

Working with Entry Attributes

Entry implements ArrayAccess for convenient attribute access.

$entry = $ldap->query()->where('uid', '=', 'jdoe')->first();

// These are equivalent:
$entry->getAttribute('cn');
$entry['cn'];

// Multi-valued attributes
foreach ($entry->getAll('memberOf') as $group) {
    echo $group . "\n";
}

// Dump entire entry
print_r($entry->toArray());

Entry is read-only. offsetSet() and offsetUnset() throw LdapException. Use EntryManager methods to modify LDAP.

Attribute Return Behavior

getAttribute() has a nuanced return:

  • If the attribute has one value → returns the string
  • If the attribute has multiple values → returns the array
  • If the attribute is missing → returns null
$cn  = $entry->getAttribute('cn');       // "John Doe" (single value)
$mem = $entry->getAttribute('memberOf'); // ['cn=g1,...', 'cn=g2,...'] (multi)

To force an array, use getAll():

$cn = $entry->getAll('cn'); // ['John Doe']

Intermediate Guide

Advanced Query Filters

Operators

The where() builder supports these operators:

Operator LDAP Filter Description
= (attr=value) Exact match
!= (!(attr=value)) Negation
contains (attr=*value*) Wildcard match
starts_with (attr=value*) Prefix
ends_with (attr=*value) Suffix
$ldap->query()
    ->where('cn', 'starts_with', 'John')
    ->where('mail', 'ends_with', '@example.com')
    ->get();

Raw Filters

For complex filters, use rawFilter(). The string is passed to LDAP unmodified, so escape user input yourself.

$ldap->query()
    ->rawFilter('(|(uid=jdoe)(mail=jdoe@example.com))')
    ->get();

OR Conditions

orWhere() accumulates OR groups that are combined separately from the AND conditions:

$ldap->query()
    ->where('objectClass', '=', 'person')       // AND
    ->orWhere('uid', '=', 'jdoe')               // OR
    ->orWhere('mail', '=', 'jdoe@example.com'); // OR

This compiles to:

(&(objectClass=person)(|(uid=jdoe)(mail=jdoe@example.com)))

Member-Of Shortcut

whereMemberOf() handles proper DN escaping for you:

$ldap->query()
    ->whereMemberOf('cn=admins,ou=groups,dc=example,dc=com')
    ->get();

Nested Conditions

Nested conditions allow you to group filters precisely.

$ldap->query()
    ->where('objectClass', '=', 'person')
    ->whereNested(function ($q) {
        $q->where('uid', '=', 'jdoe')
          ->orWhere('uid', '=', 'asmith');
    })
    ->get();

Compiles to:

(&(objectClass=person)(|(uid=jdoe)(uid=asmith)))

You can nest multiple levels:

$ldap->query()
    ->whereNested(function ($q) {
        $q->where('objectClass', '=', 'inetOrgPerson')
          ->whereNested(function ($q2) {
              $q2->where('department', '=', 'engineering')
                 ->orWhere('department', '=', 'ops');
          });
    })
    ->get();

Pagination

For large result sets, enable paged results (requires PHP 7.3+):

$entries = $ldap->query()
    ->baseDn('ou=people,dc=example,dc=com')
    ->paginate(500)   // 500 entries per page
    ->get();

The builder internally loops through pages using the LDAP_CONTROL_PAGEDRESULTS control until the server returns no more cookies. All results are merged into one flat array, so memory usage is not bounded by paging — for huge directories, process page-by-page manually with the low-level connection.

Caching Queries

Enable per-query caching to reduce LDAP traffic:

$entries = $ldap->query()
    ->where('objectClass', '=', 'inetOrgPerson')
    ->cache(600)   // cache for 600 seconds
    ->get();

The cache key is derived from the compiled filter, base DN, and selected attributes:

$key = 'ldap:' . md5($filter . '|' . $baseDn . '|' . implode(',', $attrs));

The cache is only checked on get() when cacheResults is true. Do not cache queries that are authentication-sensitive or time-sensitive.

Group Membership Checks

Entry::inGroup() resolves nested group membership recursively.

$entry = $ldap->query()->where('uid', '=', 'jdoe')->first();

if ($entry->inGroup('cn=admins,ou=groups,dc=example,dc=com')) {
    echo "User is an admin (directly or via nested group)";
}

How Nested Resolution Works

  1. Start with the entry's memberof values (direct groups).
  2. For each group, fetch its member list (using cache when available).
  3. Recursively resolve group-of-groups.
  4. De-duplicate using a visited-set to prevent cycles.

Get all groups (direct + nested):

$allGroups = $entry->getAllGroups(); // array of DN strings

Caching Group Resolution

Group member lookups are cached for 600 seconds (hard-coded) under the key:

'ldap:group_members:' . md5($groupDn)

If your directory is large and groups change frequently, adjust or disable this via your CacheManager implementation. Note the cache stores group members (used to resolve nesting), not the entry's own group list — the entry's own groups are cached on the instance property $nestedGroups for its lifetime.


Advanced Guide

Creating Entries

EntryManager::create() adds a new LDAP entry.

$ldap->create(
    'uid=jdoe,ou=people,dc=example,dc=com',
    [
        'objectClass' => ['top', 'person', 'organizationalPerson', 'inetOrgPerson'],
        'cn'          => 'John Doe',
        'sn'          => 'Doe',
        'givenName'   => 'John',
        'mail'        => 'jdoe@example.com',
        'uid'         => 'jdoe',
        'userPassword'=> 'SecureP@ssw0rd!',
    ]
);

Important: Attribute values follow LDAP conventions. Multi-valued attributes must be arrays. Single-valued attributes may be passed as strings or single-element arrays.

Updating Entries

The component offers five distinct modification methods to match LDAP semantics.

update() — Replace values on the attributes you supply

$ldap->update('uid=jdoe,ou=people,dc=example,dc=com', [
    'mail' => 'john.doe@example.com',
    'cn'   => 'John Doe',
]);

This uses ldap_modify(). Attributes not supplied are left untouched. Attributes that are supplied are fully replaced.

Deprecated: The $replace parameter on update() has no effect and is retained for backward compatibility.

addAttributes() — Add values to existing attributes

// Adds the number to the existing telephoneNumber values (multi-valued)
$ldap->addAttributes('uid=jdoe,ou=people,dc=example,dc=com', [
    'telephoneNumber' => ['+1-555-0100', '+1-555-0101'],
]);

Uses ldap_mod_add(). Fails if the attribute already has that exact value (LDAP rejects duplicates).

deleteAttributes() — Remove specific values

// Remove specific telephone numbers
$ldap->deleteAttributes('uid=jdoe,ou=people,dc=example,dc=com', [
    'telephoneNumber' => ['+1-555-0100'],
]);

// Delete the entire attribute (empty value list)
$ldap->deleteAttributes('uid=jdoe,ou=people,dc=example,dc=com', [
    'description' => [],
]);

Uses ldap_mod_del(). Fails if a value you attempt to delete is not present.

replaceAttributes() — Replace values precisely

$ldap->replaceAttributes('uid=jdoe,ou=people,dc=example,dc=com', [
    'mail' => 'newmail@example.com',
]);

Uses ldap_mod_replace(). Semantically similar to update(), but explicitly uses the ldap_mod_replace primitive.

updateAdvanced() — Batch modifications

Apply several add/remove/replace operations atomically:

use const LDAP_MODIFY_BATCH_ADD;
use const LDAP_MODIFY_BATCH_REMOVE;
use const LDAP_MODIFY_BATCH_REPLACE;

$ldap->updateAdvanced('uid=jdoe,ou=people,dc=example,dc=com', [
    [
        'attrib'  => 'telephoneNumber',
        'modtype' => LDAP_MODIFY_BATCH_ADD,
        'values'  => ['+1-555-0200'],
    ],
    [
        'attrib'  => 'mail',
        'modtype' => LDAP_MODIFY_BATCH_REPLACE,
        'values'  => ['jdoe2@example.com'],
    ],
    [
        'attrib'  => 'description',
        'modtype' => LDAP_MODIFY_BATCH_REMOVE,
        'values'  => [],
    ],
]);

Batch operations use ldap_modify_batch(). The component validates each modification before sending:

  • attrib must be a non-empty string
  • modtype must be one of the three batch constants
  • values must be an array

Invalid modifications throw LdapException with HTTP code 400.

Deleting Entries

$ldap->delete('uid=jdoe,ou=people,dc=example,dc=com');

Uses ldap_delete(). LDAP servers will reject deletion of entries that have children — delete or move them first.

LDAP Authentication

The LdapUserProvider plugs into your auth system.

1. Define a User Model

Your model must use the HasLdapEntry trait:

use Mlangeni\Machinjiri\Core\Components\LDAP\Traits\HasLdapEntry;

class User extends Model
{
    use HasLdapEntry;

    protected $fillable = ['username', 'email', 'name'];
}

2. Configure the Provider

use Mlangeni\Machinjiri\Core\Components\LDAP\LdapUserProvider;

$provider = new LdapUserProvider(
    $ldap,
    User::class,
    [
        // local_attribute => ldap_attribute
        'username' => 'uid',
        'email'    => 'mail',
        'name'     => 'cn',
    ],
    [
        'username_attribute' => 'uid',
        'search_fields'      => ['uid', 'mail'], // fallback order
        'cache_ttl'          => 300,
    ]
);

3. Retrieve & Validate

// Step 1: Find the user by credentials
$user = $provider->retrieveByCredentials([
    'username' => 'jdoe',
    'email'    => 'jdoe@example.com', // used if username lookup fails
]);

// Step 2: Validate the password against LDAP
if ($user && $provider->validateCredentials($user, ['password' => $plainPassword])) {
    // Authenticated
}

How retrieveByCredentials() Works

  1. Pull username from credentials['username'] or credentials['email'].
  2. Check cache (ldap:user:<md5(username)>, TTL from config).
  3. Iterate search_fields, executing where(field, '=', $username)->select(syncAttributes).
  4. If found, cache the Entry and synchronize a local model.
  5. Attach the Entry to the local model via setLdapEntry().

How validateCredentials() Works

  1. Extract password from credentials.
  2. Read the Entry off the user model (getLdapEntry()).
  3. Attempt a bind with the entry's DN and the supplied password.
  4. On success → true; on LdapException → logs, fires ldap.user.failed, returns false.

Security note: validateCredentials() binds on the default connection via $this->ldap->connection()->bind(...). This replaces the bound identity on that connection for its remaining lifetime. If you perform other operations on the same connection after authentication, you'll be operating as the authenticated user. Use a named, dedicated auth connection for isolation, or rebind afterward.

Synchronization Behavior

syncLocalUser() performs an upsert keyed on username:

$local = $modelClass::where('username', $username)->first();

$attributes = [];
foreach ($this->syncAttributes as $localAttr => $ldapAttr) {
    $attributes[$localAttr] = $entry->getAttribute($ldapAttr);
}
$attributes['username'] = $username;

if ($local) {
    $local->fill($attributes)->save();   // UPDATE
} else {
    $local = new $modelClass($attributes);
    $local->save();                      // INSERT
}

Note: Cached entries also trigger syncLocalUser(), so a cache hit will still hit the database for the local user. If you want to avoid unnecessary writes, add a "last synced" timestamp check in your model layer, or bypass the cache by constructing a fresh provider.

Multiple Connections

Define named connections in your config and switch between them:

// default
$ldap->connection()->query()->get();

// named
$ldap->connection('ad')->query()->get();

Manager __call() Proxy

Any method not defined on Manager is proxied to the default connection:

// These are equivalent:
$ldap->bind('cn=admin,...', 'secret');
$ldap->connection()->bind('cn=admin,...', 'secret');

CRUD Is Always on the Default Connection

create(), read(), update(), delete() delegate to EntryManager, which is bound to the default connection at construction time:

$this->entryManager = new EntryManager($this->connection(), ...);

If you need to write to a named connection, construct your own EntryManager:

use Mlangeni\Machinjiri\Core\Components\LDAP\EntryManager;

$adEntryManager = new EntryManager($ldap->connection('ad'));
$adEntryManager->create('cn=foo,dc=corp,dc=local', [...]);

API Reference

Manager

class Manager
{
    public function __construct(array $config);

    public function connection(?string $name = null): Connection;
    public function __call(string $method, array $parameters); // proxies to default connection

    public function create(string $dn, array $attrs): bool;
    public function read(string $dn, array $attributes = []): ?Entry;
    public function update(string $dn, array $attrs): bool;
    public function delete(string $dn): bool;
}
Method Description
connection($name) Return (and lazily build) the named Connection. Throws LdapException if not configured.
create() Delegate to EntryManager::create() on the default connection.
read() Delegate to EntryManager::read().
update() Delegate to EntryManager::update().
delete() Delegate to EntryManager::delete().

Connection

class Connection
{
    public function __construct(array $config, ?Logger $logger = null, ?EventListener $events = null);

    public function connect(): void;
    public function bind(string $dn, string $password): bool;
    public function close(): void;
    public function getLink();
    public function isConnected(): bool;
    public function isBound(): bool;
    public function getBaseDn(): string;
    public function query(): Builder;
    public function __destruct();
}

connect()

  • Idempotent — returns immediately if already connected.
  • Iterates hosts, using ldap_connect() for each until one succeeds.
  • Applies options from config (validated against optionValuesMap()).
  • If use_tls is true, calls ldap_start_tls() and throws on failure.
  • If credentials are set and auto_bind is true, calls bind().
  • Fires ldap.connecting / ldap.connected events.

bind($dn, $password)

  • Calls connect() first (safe — it's idempotent).
  • Fires ldap.binding / ldap.bound events.
  • Throws LdapException (code 401) on failure with the previous exception attached.

Note: ldap_bind() is not thrown-aware — it returns false on error and emits a PHP warning. The try/catch in bind() only catches PHP exceptions, not LDAP bind failures. Bind failures that return false without throwing are not handled. Consider adding explicit error checking:

if (!ldap_bind($this->link, $dn, $password)) {
    throw new LdapException('LDAP bind failed: ' . ldap_error($this->link), 401);
}

query()

Returns a fresh Builder bound to this connection.

Builder

class Builder
{
    public function __construct(Connection $connection, ?Logger $logger = null, ?EventListener $events = null, ?CacheManager $cache = null);

    // Base & filters
    public function baseDn(string $dn): self;
    public function where(string $attribute, string $operator, $value): self;
    public function orWhere(string $attribute, string $operator, $value): self;
    public function whereNested(callable $callback, string $boolean = 'and'): self;
    public function rawFilter(string $filter): self;
    public function whereMemberOf(string $groupDn): self;

    // Selection
    public function select(array $attributes = []): self;

    // Limits & controls
    public function sizeLimit(int $limit): self;
    public function timeLimit(int $seconds): self;
    public function scope(int $scope): self;
    public function paginate(int $pageSize = 1000): self;
    public function cache(int $ttl = 300): self;

    // Execution
    public function get(): array;      // Entry[]
    public function first(): ?Entry;
}

Filter Compilation

compileFilter() builds the LDAP filter string:

  • Empty wheres → (objectClass=*)
  • Single AND condition → (attr=val)
  • Multiple AND conditions → (&(a=1)(b=2))
  • OR groups → (|(a=1)(b=2)), appended as an AND clause
  • Nested → recursively compiled; or boolean pushes to OR group, and pushes to AND conditions

Example:

$ldap->query()
    ->where('objectClass', '=', 'person')
    ->orWhere('uid', '=', 'jdoe')
    ->orWhere('uid', '=', 'asmith')
    ->whereNested(fn ($q) => $q->where('dept', '=', 'eng'));

Compiles to:

(&(objectClass=person)(|(uid=jdoe)(uid=asmith))(&(dept=eng)))

Watch out: (dept=eng) as a single-element nested AND compiles to (&(dept=eng)) which LDAP accepts but is redundant. Also, each nested closure creates a new Builder instance — it inherits the parent's connection, logger, events, and cache, so closure compilation is safe.

get()

  1. Compiles the filter.
  2. Checks the cache when cacheResults && cache is set.
  3. If paging enabled, loops ldap_search with LDAP_CONTROL_PAGEDRESULTS until the cookie is empty.
  4. Otherwise, single ldap_search.
  5. Parses raw entries into Entry objects (passing base DN, but not the cache — each Entry resolves the CacheManager via resolve() in its constructor).
  6. Caches the result when enabled.
  7. Fires ldap.query.before / ldap.query.after.

Bug watch: array_merge($entries, ldap_get_entries(...)) on the first iteration merges with [] (empty array) and produces an array with mixed keys because ldap_get_entries returns an array with a count key. Subsequent merges append numeric entries correctly, but parseEntries() later reads $rawEntries['count'] — after array_merge, the count key reflects only the last page. This means paging with multiple pages is broken: parseEntries() iterates $rawEntries['count'] times, but the array has more numeric entries and lost its accurate count. Consider replacing with a manual array_push loop that strips the count key.

first()

Returns $results[0] ?? null. Combined with sizeLimit(1), gives an efficient single-result query.

Entry

class Entry implements ArrayAccess
{
    public function __construct(array $data, string $baseDn, ?CacheManager $cache = null);

    public function getDn(): string;
    public function getAttribute(string $name);
    public function getAll(string $name): array;
    public function getAttributes(): array;
    public function toArray(): array;
    public function inGroup(string $groupDn): bool;
    public function getAllGroups(): array;

    // ArrayAccess: read-only
}
Method Behavior
getDn() Returns $data['dn']
getAttribute($name) Single value as string, multi-value as array, missing as null
getAll($name) Always an array (empty if missing)
getAttributes() Full raw data array (includes count, numeric keys, dn)
inGroup($dn) Case-insensitive membership check (resolves nesting)
getAllGroups() All direct + nested group DNs
offsetExists($k) isset($data[$k])
offsetGet($k) Alias for getAttribute($k)
offsetSet/Unset Throws LdapException

getAttributes() Caution

The raw data still contains LDAP's normalized structure:

[
    'count' => 3,
    0       => 'cn',
    'cn'    => ['count' => 1, 0 => 'John Doe'],
    ...
]

This includes count keys and numeric duplicates. If you need a clean associative array, transform it yourself:

$clean = [];
foreach ($entry->getAttributes() as $key => $value) {
    if (is_string($key) && $key !== 'count') {
        $clean[$key] = is_array($value)
            ? array_values(array_filter($value, 'is_string'))
            : $value;
    }
}

EntryManager

class EntryManager
{
    public function __construct(Connection $connection, ?Logger $logger = null, ?EventListener $events = null);

    public function create(string $dn, array $attrs): bool;
    public function read(string $dn, array $attributes = []): ?Entry;
    public function update(string $dn, array $newAttrs, bool $replace = true): bool; // deprecated $replace
    public function addAttributes(string $dn, array $attrs): bool;
    public function deleteAttributes(string $dn, array $attrs): bool;
    public function replaceAttributes(string $dn, array $attrs): bool;
    public function updateAdvanced(string $dn, array $modifications): bool;
    public function delete(string $dn): bool;
}

All methods fire lifecycle events and log on failure.

Error Handling

Each modification path checks the boolean return of the underlying ldap_* call. However, native ldap_add, ldap_modify, ldap_mod_add, ldap_mod_del, ldap_mod_replace, ldap_modify_batch, and ldap_delete return false and emit a PHP warning rather than throwing. The current try/catch blocks won't catch these — they only catch thrown exceptions.

The code calls ldap_error($link) and throws LdapException when the return is falsy (for update/add/delete attribute paths and delete). But create() and read() only check try/catch, so a false return from ldap_add or ldap_read is not converted to an exception:

  • create() — ldap_add($link, $dn, $attrs) return value is ignored; false will silently "succeed"
  • read() — ldap_read() return value is checked, returns null if falsy (correct)
  • delete() — return value is checked, throws on failure (correct)

Recommendation: Treat create() as potentially silent on failure, or patch it to:

if (!ldap_add($link, $dn, $attrs)) {
    throw new LdapException('Failed to create entry: ' . ldap_error($link), 500);
}

LdapUserProvider

class LdapUserProvider implements Contracts\LdapUserProvider
{
    public function __construct(Manager $ldap, string $modelClass, array $syncAttributes, array $config = []);

    public function retrieveByCredentials(array $credentials);
    public function validateCredentials($user, array $credentials): bool;
}

Config Keys

Key Default Description
username_attribute uid LDAP attribute used as the local username
search_fields ['uid', 'mail'] Ordered LDAP attributes to search
cache_ttl 300 Seconds to cache the LDAP Entry

Events

Event Payload
ldap.user.retrieving ['username' => $username]
ldap.user.notfound ['username' => $username]
ldap.user.retrieved ['user' => $model, 'entry' => $entry]
ldap.user.validating ['dn' => $dn]
ldap.user.validated ['user' => $model]
ldap.user.failed ['user' => $model, 'exception' => $e]

Contract

namespace Mlangeni\Machinjiri\Core\Components\LDAP\Contracts;

interface LdapUserProvider
{
    public function retrieveByCredentials(array $credentials);
    public function validateCredentials($user, array $credentials): bool;
}

Events

The component fires events at every stage. Subscribe via your EventListener implementation.

Event Payload Fired By
ldap.connecting $config Connection::connect()
ldap.connected $connection Connection::connect()
ldap.binding ['dn' => $dn] Connection::bind()
ldap.bound ['dn' => $dn] Connection::bind()
ldap.query.before $builder Builder::get()
ldap.query.after ['entries' => $entries, 'filter' => $filter] Builder::get()
ldap.entry.creating ['dn' => $dn, 'attrs' => $attrs] EntryManager::create()
ldap.entry.created ['dn' => $dn] EntryManager::create()
ldap.entry.updating ['dn' => $dn, 'attrs' => $attrs] EntryManager::modifyAttributes()
ldap.entry.updated ['dn' => $dn] EntryManager::modifyAttributes()
ldap.entry.deleting ['dn' => $dn] EntryManager::delete()
ldap.entry.deleted ['dn' => $dn] EntryManager::delete()
ldap.user.retrieving ['username' => $username] LdapUserProvider
ldap.user.notfound ['username' => $username] LdapUserProvider
ldap.user.retrieved ['user' => $model, 'entry' => $entry] LdapUserProvider
ldap.user.validating ['dn' => $dn] LdapUserProvider
ldap.user.validated ['user' => $model] LdapUserProvider
ldap.user.failed ['user' => $model, 'exception' => $e] LdapUserProvider

Security Considerations

  1. Always use TLS or SSL in production. Set use_ssl => true (port 636) or use_tls => true (STARTTLS on 389). Plain LDAP transmits credentials in cleartext.

  2. Escape all user input in filters. Use where() or whereMemberOf() — they call ldap_escape() with LDAP_ESCAPE_FILTER. If you use rawFilter(), escape manually:

    $value = ldap_escape($userInput, '', LDAP_ESCAPE_FILTER);
    $ldap->query()->rawFilter("(uid={$value})");
  3. DN values must be escaped too. whereMemberOf() handles this with LDAP_ESCAPE_FILTER — but DN special characters (,, +, ", \, <, >, ;) also need escaping for DN context (LDAP_ESCAPE_DN) if you interpolate DNs in baseDn(). The builder does not escape baseDn() input.

  4. Bind passwords are not logged. Connection::bind() logs the DN but the framework's LdapException includes the DN in the context — it never includes the password. Keep it that way in custom extensions.

  5. Prefer service-account binds for searching, and separate user-auth binds for password validation. Avoid mixing them on the same connection.

  6. Rate-limit authentication attempts at the application layer. LDAP itself has no brute-force protection.

  7. Cache entries cautiously. Cached LDAP entries may contain stale PII or authorization-relevant group data. Set appropriate TTLs and clear on user events.

  8. Do not store plaintext passwords from LDAP. The userPassword attribute (if readable) is typically hashed; never sync it to your local DB.


Troubleshooting

"Could not connect to any LDAP host"

  • Verify hosts and port.
  • Confirm use_ssl / use_tls matches your server's port and configuration.
  • Check LDAP_OPT_NETWORK_TIMEOUT is set (default has none).
  • Test connectivity: openssl s_client -connect host:636 or ldapsearch -H ldap://host:389 -x -b "" -s base.

"TLS failed: ..."

  • STARTTLS requires a successful plaintext handshake first. Ensure port 389 is open.
  • Some servers require LDAP_OPT_X_TLS_REQUIRE_CERT configuration — set via ldap_set_option(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER) in dev (never in prod).
  • Certificates must be signed by a trusted CA in openssl.cafile.

"LDAP bind failed"

  • Verify the bind DN format (AD uses user@domain or DOMAIN\user; OpenLDAP uses full DN).
  • Check the password.
  • Ensure the service account has search permissions on the base DN.

"LDAP search failed: ..."

  • Check that the baseDn exists.
  • Verify the bound account has read access to the attributes being selected.
  • If size limit exceeded, use paginate() or sizeLimit().

"Invalid LDAP batch modification"

  • Each element of updateAdvanced() must be ['attrib' => string, 'modtype' => int, 'values' => array].
  • modtype must be one of LDAP_MODIFY_BATCH_ADD, LDAP_MODIFY_BATCH_REMOVE, LDAP_MODIFY_BATCH_REPLACE.

Nested group resolution is slow or wrong

  • Increase the cache TTL for ldap:group_members:*.
  • Verify your directory server supports memberOf and member (AD supports both; OpenLDAP requires the memberof overlay).
  • Cycles are handled via the $visited set — infinite loops shouldn't occur, but verify your data if you get unexpected results.

Paged results return incomplete data

  • See the bug watch in the Builder::get() section: array_merge loses the count key across pages. If you're paging a large directory and get() returns fewer than expected, this is why. Work around it by iterating ldap_search manually or patching Builder::get().

Best Practices

  1. Always set LDAP_OPT_PROTOCOL_VERSION => 3. LDAPv2 is deprecated and many servers reject it.

  2. Set LDAP_OPT_NETWORK_TIMEOUT. Without it, a dead host can hang your request for the OS default timeout.

  3. Disable referrals (LDAP_OPT_REFERRALS => 0) unless you specifically need them. AD in particular behaves unpredictably with referrals enabled in PHP.

  4. Select only the attributes you need. select(['cn', 'mail']) drastically reduces payload on large directories.

  5. Use read($dn) for single-entry lookups, not where('dn', '=', ...) — ldap_read is scoped to the base and far faster.

  6. Enable sizeLimit(1) with first() to short-circuit server-side on large trees.

  7. Cache aggressively but with awareness. LDAP queries can be expensive; cache(300) on user lookups is reasonable. Avoid caching authentication results.

  8. Use named connections for isolation. Keep a service connection for searching and an auth connection for password validation.

  9. Log at the DN level, not the password level. The component does this correctly — preserve the pattern in any extensions.

  10. Test against a real LDAP server (OpenLDAP + slapd or an AD test instance). The ldap_* functions behave subtly differently across implementations.

  11. Treat Entry::getAttributes() as diagnostic. Its raw structure includes count and numeric keys. Use getAttribute() / getAll() for business logic.

  12. Bind the base DN in your config to a service account with read-only access. Never use a directory admin account for routine operations.

  13. Batch modifications with updateAdvanced() when you need multiple atomic operations — it's a single LDAP round-trip and fails atomically.


Examples

Example 1: User Directory Search

use Mlangeni\Machinjiri\Core\Components\LDAP\Manager;

$ldap = new Manager(config('ldap'));

$results = $ldap->query()
    ->baseDn('ou=people,dc=example,dc=com')
    ->where('objectClass', '=', 'inetOrgPerson')
    ->whereNested(function ($q) {
        $q->where('cn', 'contains', 'Smith')
          ->orWhere('mail', 'ends_with', '@example.com');
    })
    ->select(['cn', 'mail', 'uid', 'telephoneNumber'])
    ->sizeLimit(100)
    ->cache(300)
    ->get();

foreach ($results as $entry) {
    printf(
        "%s <%s> — %s\n",
        $entry->getAttribute('cn'),
        $entry->getAttribute('mail') ?? 'no email',
        $entry->getAttribute('uid')
    );
}

Example 2: Group-Based Authorization

$entry = $ldap->query()
    ->where('uid', '=', 'jdoe')
    ->first();

if ($entry && $entry->inGroup('cn=administrators,ou=groups,dc=example,dc=com')) {
    // Grant admin access
}

// List all groups (including nested) for audit
foreach ($entry->getAllGroups() as $groupDn) {
    echo $groupDn . "\n";
}

Example 3: Create a User with Multiple Attributes

$ldap->create(
    'uid=asmith,ou=people,dc=example,dc=com',
    [
        'objectClass'    => ['top', 'person', 'organizationalPerson', 'inetOrgPerson'],
        'cn'             => 'Alice Smith',
        'sn'             => 'Smith',
        'givenName'      => 'Alice',
        'uid'            => 'asmith',
        'mail'           => 'asmith@example.com',
        'telephoneNumber'=> ['+1-555-0100', '+1-555-0199'],
        'department'     => 'Engineering',
        'title'          => 'Senior Engineer',
        'userPassword'   => 'Ch4ng3M3!',
    ]
);

Example 4: Atomic Batch Update

use const LDAP_MODIFY_BATCH_ADD;
use const LDAP_MODIFY_BATCH_REMOVE;
use const LDAP_MODIFY_BATCH_REPLACE;

$dn = 'uid=asmith,ou=people,dc=example,dc=com';

$ldap->updateAdvanced($dn, [
    // Add a new phone number
    [
        'attrib'  => 'telephoneNumber',
        'modtype' => LDAP_MODIFY_BATCH_ADD,
        'values'  => ['+1-555-0300'],
    ],
    // Replace the mail address
    [
        'attrib'  => 'mail',
        'modtype' => LDAP_MODIFY_BATCH_REPLACE,
        'values'  => ['alice.smith@example.com'],
    ],
    // Remove the entire description attribute
    [
        'attrib'  => 'description',
        'modtype' => LDAP_MODIFY_BATCH_REMOVE,
        'values'  => [],
    ],
]);

Example 5: LDAP Authentication with Local Sync

use Mlangeni\Machinjiri\Core\Components\LDAP\LdapUserProvider;
use App\Models\User;

$provider = new LdapUserProvider(
    $ldap,
    User::class,
    [
        'username' => 'uid',
        'email'    => 'mail',
        'name'     => 'cn',
    ],
    [
        'username_attribute' => 'uid',
        'search_fields'      => ['uid', 'mail', 'sAMAccountName'], // AD compat
        'cache_ttl'          => 300,
    ]
);

$credentials = $request->only('username', 'password');

$user = $provider->retrieveByCredentials($credentials);

if (!$user) {
    return response()->json(['error' => 'User not found'], 404);
}

if (!$provider->validateCredentials($user, $credentials)) {
    return response()->json(['error' => 'Invalid credentials'], 401);
}

// $user is a local User model, freshly synced from LDAP
auth()->login($user);
return response()->json(['user' => $user]);

Example 6: Active Directory Multi-Domain Setup

$config = [
    'default' => 'corp',
    'connections' => [
        'corp' => [
            'hosts'    => ['ad1.corp.local', 'ad2.corp.local'],
            'port'     => 636,
            'base_dn'  => 'dc=corp,dc=local',
            'username' => 'svc-ldap@corp.local',
            'password' => env('AD_PASSWORD'),
            'use_ssl'  => true,
            'auto_bind'=> true,
            'options'  => [
                'LDAP_OPT_PROTOCOL_VERSION' => 3,
                'LDAP_OPT_REFERRALS'        => 0,
                'LDAP_OPT_NETWORK_TIMEOUT'  => 5,
            ],
        ],
        'partners' => [
            'hosts'    => ['dc01.partners.example'],
            'port'     => 389,
            'base_dn'  => 'dc=partners,dc=example',
            'username' => 'cn=readonly,dc=partners,dc=example',
            'password' => env('PARTNER_LDAP_PASSWORD'),
            'use_tls'  => true,
            'auto_bind'=> true,
            'options'  => [
                'LDAP_OPT_PROTOCOL_VERSION' => 3,
                'LDAP_OPT_REFERRALS'        => 0,
            ],
        ],
    ],
];

$ldap = new Manager($config);

// Search corporate directory
$corpUsers = $ldap->connection('corp')->query()
    ->where('objectClass', '=', 'user')
    ->where('sAMAccountName', 'starts_with', 'smith')
    ->get();

// Search partner directory
$partnerUsers = $ldap->connection('partners')->query()
    ->where('uid', 'contains', 'smith')
    ->get();

Clone this wiki locally