Repository navigation
LDAP
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
┌─────────────────────────────────────────────────────────────┐
│ 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() │
└───────────────┘
- PHP 8.4+
- PHP LDAP extension (
ext-ldap) — required - OpenLDAP or Active Directory server
-
ext-ldapwith support forldap_modify_batch(PHP 7.2+, for batch operations) -
ext-ldapwithLDAP_CONTROL_PAGEDRESULTS(PHP 7.3+, for pagination)
Verify your environment:
php -m | grep ldap
php -r "var_dump(function_exists('ldap_modify_batch'));"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/"
}
}
}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,
],
],
],
];| 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_sslanduse_tlsare mutually exclusive in practice. Useuse_sslfor encrypted-from-start connections (port 636) anduse_tlsfor STARTTLS upgrade (port 389).
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";
}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 succeededTo close a connection:
$connection->close();The
Connectionclass implements__destruct()and will close the LDAP resource when it goes out of scope, but explicitclose()is preferred in long-running processes.
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');
}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 callsldap_search()internally, which is subtree-scoped at the C level. To restrict scope, you may need to add a filter or usebase_dnmore strategically. For single-entry lookups, prefer$ldap->read($dn).
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']);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());
Entryis read-only.offsetSet()andoffsetUnset()throwLdapException. UseEntryManagermethods to modify LDAP.
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']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();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();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'); // ORThis compiles to:
(&(objectClass=person)(|(uid=jdoe)(mail=jdoe@example.com)))
whereMemberOf() handles proper DN escaping for you:
$ldap->query()
->whereMemberOf('cn=admins,ou=groups,dc=example,dc=com')
->get();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();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.
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()whencacheResultsistrue. Do not cache queries that are authentication-sensitive or time-sensitive.
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)";
}- Start with the entry's
memberofvalues (direct groups). - For each group, fetch its
memberlist (using cache when available). - Recursively resolve group-of-groups.
- De-duplicate using a visited-set to prevent cycles.
Get all groups (direct + nested):
$allGroups = $entry->getAllGroups(); // array of DN stringsGroup 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.
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.
The component offers five distinct modification methods to match LDAP semantics.
$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
$replaceparameter onupdate()has no effect and is retained for backward compatibility.
// 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).
// 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.
$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.
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:
-
attribmust be a non-empty string -
modtypemust be one of the three batch constants -
valuesmust be an array
Invalid modifications throw LdapException with HTTP code 400.
$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.
The LdapUserProvider plugs into your auth system.
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'];
}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,
]
);// 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
}- Pull
usernamefromcredentials['username']orcredentials['email']. -
Check cache (
ldap:user:<md5(username)>, TTL from config). - Iterate
search_fields, executingwhere(field, '=', $username)->select(syncAttributes). - If found, cache the
Entryand synchronize a local model. - Attach the
Entryto the local model viasetLdapEntry().
- Extract
passwordfrom credentials. - Read the
Entryoff the user model (getLdapEntry()). - Attempt a bind with the entry's DN and the supplied password.
- On success →
true; onLdapException→ logs, firesldap.user.failed, returnsfalse.
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.
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.
Define named connections in your config and switch between them:
// default
$ldap->connection()->query()->get();
// named
$ldap->connection('ad')->query()->get();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');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', [...]);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(). |
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();
}- Idempotent — returns immediately if already connected.
- Iterates
hosts, usingldap_connect()for each until one succeeds. - Applies
optionsfrom config (validated againstoptionValuesMap()). - If
use_tlsis true, callsldap_start_tls()and throws on failure. - If credentials are set and
auto_bindis true, callsbind(). - Fires
ldap.connecting/ldap.connectedevents.
- Calls
connect()first (safe — it's idempotent). - Fires
ldap.binding/ldap.boundevents. - Throws
LdapException(code401) on failure with the previous exception attached.
Note:
ldap_bind()is not thrown-aware — it returnsfalseon error and emits a PHP warning. Thetry/catchinbind()only catches PHP exceptions, not LDAP bind failures. Bind failures that returnfalsewithout 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);
}Returns a fresh Builder bound to this connection.
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;
}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;
orboolean pushes to OR group,andpushes 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 newBuilderinstance — it inherits the parent's connection, logger, events, and cache, so closure compilation is safe.
- Compiles the filter.
- Checks the cache when
cacheResults && cacheis set. - If paging enabled, loops
ldap_searchwithLDAP_CONTROL_PAGEDRESULTSuntil the cookie is empty. - Otherwise, single
ldap_search. - Parses raw entries into
Entryobjects (passing base DN, but not the cache — eachEntryresolves theCacheManagerviaresolve()in its constructor). - Caches the result when enabled.
- 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 becauseldap_get_entriesreturns an array with acountkey. Subsequent merges append numeric entries correctly, butparseEntries()later reads$rawEntries['count']— afterarray_merge, thecountkey 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 manualarray_pushloop that strips thecountkey.
Returns $results[0] ?? null. Combined with sizeLimit(1), gives an efficient single-result query.
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
|
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;
}
}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.
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;falsewill silently "succeed" -
read()—ldap_read()return value is checked, returnsnullif 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);
}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;
}| 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
|
| 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] |
namespace Mlangeni\Machinjiri\Core\Components\LDAP\Contracts;
interface LdapUserProvider
{
public function retrieveByCredentials(array $credentials);
public function validateCredentials($user, array $credentials): bool;
}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 |
-
Always use TLS or SSL in production. Set
use_ssl => true(port 636) oruse_tls => true(STARTTLS on 389). Plain LDAP transmits credentials in cleartext. -
Escape all user input in filters. Use
where()orwhereMemberOf()— they callldap_escape()withLDAP_ESCAPE_FILTER. If you userawFilter(), escape manually:$value = ldap_escape($userInput, '', LDAP_ESCAPE_FILTER); $ldap->query()->rawFilter("(uid={$value})");
-
DN values must be escaped too.
whereMemberOf()handles this withLDAP_ESCAPE_FILTER— but DN special characters (,,+,",\,<,>,;) also need escaping for DN context (LDAP_ESCAPE_DN) if you interpolate DNs inbaseDn(). The builder does not escapebaseDn()input. -
Bind passwords are not logged.
Connection::bind()logs the DN but the framework'sLdapExceptionincludes the DN in the context — it never includes the password. Keep it that way in custom extensions. -
Prefer service-account binds for searching, and separate user-auth binds for password validation. Avoid mixing them on the same connection.
-
Rate-limit authentication attempts at the application layer. LDAP itself has no brute-force protection.
-
Cache entries cautiously. Cached LDAP entries may contain stale PII or authorization-relevant group data. Set appropriate TTLs and clear on user events.
-
Do not store plaintext passwords from LDAP. The
userPasswordattribute (if readable) is typically hashed; never sync it to your local DB.
- Verify
hostsandport. - Confirm
use_ssl/use_tlsmatches your server's port and configuration. - Check
LDAP_OPT_NETWORK_TIMEOUTis set (default has none). - Test connectivity:
openssl s_client -connect host:636orldapsearch -H ldap://host:389 -x -b "" -s base.
- STARTTLS requires a successful plaintext handshake first. Ensure port 389 is open.
- Some servers require
LDAP_OPT_X_TLS_REQUIRE_CERTconfiguration — set vialdap_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.
- Verify the bind DN format (AD uses
user@domainorDOMAIN\user; OpenLDAP uses full DN). - Check the password.
- Ensure the service account has search permissions on the base DN.
- Check that the
baseDnexists. - Verify the bound account has read access to the attributes being selected.
- If size limit exceeded, use
paginate()orsizeLimit().
- Each element of
updateAdvanced()must be['attrib' => string, 'modtype' => int, 'values' => array]. -
modtypemust be one ofLDAP_MODIFY_BATCH_ADD,LDAP_MODIFY_BATCH_REMOVE,LDAP_MODIFY_BATCH_REPLACE.
- Increase the cache TTL for
ldap:group_members:*. - Verify your directory server supports
memberOfandmember(AD supports both; OpenLDAP requires thememberofoverlay). - Cycles are handled via the
$visitedset — infinite loops shouldn't occur, but verify your data if you get unexpected results.
- See the bug watch in the
Builder::get()section:array_mergeloses thecountkey across pages. If you're paging a large directory andget()returns fewer than expected, this is why. Work around it by iteratingldap_searchmanually or patchingBuilder::get().
-
Always set
LDAP_OPT_PROTOCOL_VERSION => 3. LDAPv2 is deprecated and many servers reject it. -
Set
LDAP_OPT_NETWORK_TIMEOUT. Without it, a dead host can hang your request for the OS default timeout. -
Disable referrals (
LDAP_OPT_REFERRALS => 0) unless you specifically need them. AD in particular behaves unpredictably with referrals enabled in PHP. -
Select only the attributes you need.
select(['cn', 'mail'])drastically reduces payload on large directories. -
Use
read($dn)for single-entry lookups, notwhere('dn', '=', ...)—ldap_readis scoped to the base and far faster. -
Enable
sizeLimit(1)withfirst()to short-circuit server-side on large trees. -
Cache aggressively but with awareness. LDAP queries can be expensive;
cache(300)on user lookups is reasonable. Avoid caching authentication results. -
Use named connections for isolation. Keep a
serviceconnection for searching and anauthconnection for password validation. -
Log at the DN level, not the password level. The component does this correctly — preserve the pattern in any extensions.
-
Test against a real LDAP server (OpenLDAP +
slapdor an AD test instance). Theldap_*functions behave subtly differently across implementations. -
Treat
Entry::getAttributes()as diagnostic. Its raw structure includescountand numeric keys. UsegetAttribute()/getAll()for business logic. -
Bind the base DN in your config to a service account with read-only access. Never use a directory admin account for routine operations.
-
Batch modifications with
updateAdvanced()when you need multiple atomic operations — it's a single LDAP round-trip and fails atomically.
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')
);
}$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";
}$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!',
]
);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' => [],
],
]);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]);$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();Machinjiri Docs
- Home
- Learn
- Build
-
Tutorials
- Routing
- Controllers and Middleware
- Views and Templates
- Database ORM
- Eloquent Database ORM
- Errors and Exceptions
- Forms and Validation
- Http
- Events and Logging
- Services and DI
- Security
- Authentication
- Caching
- File System
- Date and Time
- Debugging
- Queue and Jobs
- Task Scheduling
- Transport (Mail, SMS)
- LDAP
- Notifications
- Webhooks
- UUID Generations
- Terminal
- Helper Function
- Reference