gorg/ldaporm-bundle
A Symfony bundle that integrates LDAP ORM into your application, providing configuration and service wiring for mapping LDAP directory entries to PHP objects. Query and manage LDAP-backed data with familiar object-oriented models.
Know what you're installing first. gorg/ldaporm-bundle is a Symfony 2 bundle (PHP 5.3/5.4 era, last real activity ~2014). It is not a Laravel package: no service provider, no facade, no publishable config. What actually carries the value is the framework-agnostic core, Gorg\LdapOrm — an EntityManager + annotation metadata + query builder wrapped around raw ext-ldap. On Laravel you either hand-wire that core, or you copy the pattern and rebuild it over directorytree/ldaprecord. Everything below assumes you're hand-wiring the core or reading it as a design reference.
Where to look first, in this order:
DependencyInjection/Configuration.php + Resources/config/*.yml — the real connection option names (host, port, base_dn, bind credentials, TLS flags). This is your config contract even if you never boot Symfony.Annotation/Entity, Annotation/Attribute, Annotation/Id) — this is the mapping vocabulary, and it defines what your "model" classes look like.EntityManager and the query builder — find findBy, createQueryBuilder, persist/flush, and how filters get assembled. Trace one findBy end to end before writing code.Repository base class — the intended extension point for custom finders.Minimal wiring (Symfony 2, if you're stabilizing a legacy app):
// app/AppKernel.php
new Gorg\LdapOrmBundle\GorgLdapOrmBundle(),
# app/config/config.yml
gorg_ldap_orm:
connections:
default:
host: ldap.internal
port: 389
base_dn: "dc=example,dc=com"
bind_dn: "cn=svc-app,ou=services,dc=example,dc=com"
bind_password: "%ldap_password%"
use_tls: true
Then an entity (annotation style — this is the part you must rewrite if you move it forward):
namespace Acme\UserBundle\Entity;
use Gorg\LdapOrm\Annotation as Ldap;
/**
* @Ldap\Entity(objectClass="inetOrgPerson", baseDn="ou=people,dc=example,dc=com")
*/
class User
{
/** @Ldap\Id */
public $dn;
/** @Ldap\Attribute(name="uid") */
public $uid;
/** @Ldap\Attribute(name="mail") */
public $email;
/** @Ldap\Attribute(name="cn") */
public $commonName;
}
First use case — read-only identity lookup. Forget writes on day one. Pick the single highest-value read: "given a login, return uid, mail, display name, and group memberships." Fetch the entity manager from the container, call the repository, render. If that path works end to end you have 80% of what this package is good for; everything after that is edge cases.
$em = $container->get('gorg_ldap_orm.entity_manager');
$repo = $em->getRepository(User::class);
$user = $repo->findOneBy(['uid' => $login]);
If you're on Laravel: don't install the bundle. Spike the core library directly — instantiate the EntityManager with a config array by hand (no container needed for a read path), run it against a containerized OpenLDAP, and use that to decide whether the abstraction is worth owning. Budget a day, not a sprint.
One entity per objectClass, and only the attributes you use. LDAP entries have dozens of attributes; hydrating all of them is the fastest way to slow pages and confusing bugs. Map inetOrgPerson for users, groupOfNames/posixGroup for groups, organizationalUnit for containers. Leave the rest unmapped — you can add them later without migration pain because LDAP is schemaless from your side.
/**
* @Ldap\Entity(objectClass="groupOfNames", baseDn="ou=groups,dc=example,dc=com")
*/
class Group
{
/** @Ldap\Id */ public $dn;
/** @Ldap\Attribute(name="cn") */ public $name;
/** @Ldap\Attribute(name="member") */ public $members = []; // multi-valued
}
Always normalise multi-valued attributes in the entity, never at the call site. ext-ldap returns every attribute as an array; the ORM may or may not collapse single-valued ones depending on your mapping. Wrap each mapped property in a getter that returns a scalar or array consistently. Doing this once in the entity stops foreach ($user->email) bugs from spreading.
Extend the repository, don't scatter filters. The package's repository is the intended seam. Put every non-trivial filter in a named method so the filter string exists in exactly one place — and so you can unit-test it.
class UserRepository extends LdapRepository
{
public function findByUid(string $uid): ?User
{
return $this->findOneBy(['uid' => ldap_escape($uid, '', LDAP_ESCAPE_FILTER)]);
}
public function findActiveInOu(string $ou): array
{
return $this->createQueryBuilder()
->select('uid', 'mail', 'cn')
->where('objectClass', 'inetOrgPerson')
->andWhere('ou', $ou)
->andWhere('!(accountStatus=disabled)')
->getQuery()
->execute();
}
}
QueryBuilder: chain it, log it, then run it. The fluent builder is the nicest thing here — but it also hides the filter string, and the filter string is the only thing LDAP actually understands. Log the compiled filter at debug level before execution, and when something returns nothing, paste that filter straight into ldapsearch -x -LLL -b "dc=example,dc=com" "<filter>" to see whether the bug is yours or the directory's.
Hydration discipline: select only what you render. Full-object hydration on list pages is the classic performance mistake. If the builder supports partial/array hydration, use it for indexes and only hydrate entities for detail views. Otherwise, select the attribute list explicitly.
Persist flow: treat it as CRUD on a remote system with no transactions. The typical write looks like:
$group = new Group();
$group->dn = 'cn=eng,ou=groups,dc=example,dc=com';
$group->name = 'eng';
$group->members = ['uid=alice,ou=people,dc=example,dc=com'];
$em->persist($group);
$em->flush(); // ldap_add / ldap_modify under the hood
There is no unit of work and no rollback. If it fails halfway through a sequence, you clean up by hand. For anything spanning more than one entry, wrap it in your own application-level "create with compensation" method that deletes partial results on failure.
Group membership: prefer server-side resolution over N+1 loops. Two common shapes:
member: on groupOfNames, memberUid: on posixGroup) — search groups where member=<userdn>, one query.memberOf) — read the attribute, one query.Do not fetch every group and hydrate it to check membership. That's a full-subtree scan and it's how LDAP integrations get paged off the search path.
Authentication is a separate code path from your ORM. User login is a bind, not a query: bind the connection with the service account, search for the DN by uid, then attempt a second bind as that DN with the supplied password. Do not compare password hashes — LDAP never gives you the hash. If the package's entity manager exposes the connection, reuse it; otherwise open a dedicated bind per auth attempt and close it immediately.
Insulate callers behind your own interface. Even inside Symfony, wrap the package:
interface DirectoryRepository
{
public function findByDn(string $dn): ?object;
public function search(array $criteria): array;
public function bind(string $dn, string $password): bool;
}
Two methods per domain query you need. When you later swap to LdapRecord or the Symfony Ldap component, the call sites don't move. This is the single highest-leverage thing you can do with this package — treat it as an implementation detail from day one.
Testing: containerize the directory, fixtures via LDIF. docker-compose with osixia/openldap or bitnami/openldap, a fixtures.ldif loaded at boot, and integration tests that assert on real ldap_search behaviour. Unit-testing against a mocked LDAP client is worthless here — every interesting bug is at the protocol/schema layer (escaping, paging, case, referrals).
Multiple connections: name them and keep them separate. The bundle config supports more than one; use a read-only service account for the app and a privileged one only in the provisioning path. Never let the request-serving code hold write credentials.
There is no Laravel path. No php artisan vendor:publish, no container binding, no Eloquent. Symfony 2's PHP floor (5.x–7.0) is incompatible with Laravel 10+ (PHP 8.1+). Composer will not resolve it into a modern app; --ignore-platform-reqs gets you past the constraint check and straight into fatal errors. Any Laravel use is a re-implementation.
The license is unresolved — NOASSERTION is a stop sign, not a warning. Before you copy a single class (not just before you composer require), get the license established. "It's on GitHub" is not a license. This is a legal gate; route it early, not after the prototype.
Annotation mapping breaks on modern PHP. Doctrine annotations were deprecated in doctrine/annotations 1.13 and the package predates PHP 8 attributes. If you're moving this code forward, budget a mechanical rewrite of every @Ldap\* annotation into attributes or array config — and expect to own the metadata driver, not patch it.
Escape every value you put in a filter. Unescaped user input in a search filter is LDAP injection: *)(uid=* will happily dump the directory. Use ldap_escape($value, '', LDAP_ESCAPE_FILTER) — always, in the repository, not at the call site. And escape DN components with LDAP_ESCAPE_DN, especially for RDNs containing , + " \ < > ;.
Binary attributes will ruin your day. objectGUID, objectSid, jpegPhoto, and certificates come back as binary. objectGUID in particular is a raw 16-byte blob — it must be converted to a GUID string before it appears in JSON, a URL, or a log line. Add an explicit converter; don't let var_dump be your first encounter with it.
AD silently truncates at 1000 entries. Without a paged-results control (LDAP_CONTROL_PAGEDRESULTS with a cookie loop), large searches return a partial set with no error — it looks complete. This is a correctness bug, not a performance issue. If the query builder doesn't paginate, assert expected counts in tests and treat "fewer results than I know exist" as a hard failure.
Result attribute names are lowercase regardless of how they're stored. sAMAccountName comes back as samaccountname. Mapping is case-insensitive in your annotations, but any code that indexes the raw array by the "correct" casing will silently miss.
Paging state + connection reuse. Cookies are per-connection. If the entity manager holds a long-lived connection across a long-running worker, a stale cookie will hang or repeat results. Rebind per request/batch, and never share an EntityManager across a forked queue worker.
Set timeouts explicitly. ext-ldap has no useful default network timeout; a hung directory exhausts PHP-FPM workers before max_execution_time ever fires, because the time is spent in a blocking syscall. Set LDAP_OPT_NETWORK_TIMEOUT/LDAP_OPT_TIMEOUT in the connection setup, and set them shorter than your PHP timeout.
TLS: choose 636 vs StartTLS deliberately, and verify certificates. ldaps:// on 636 and StartTLS on 389 are different handshakes — configure one, not both. Many old PHP LDAP setups disable peer verification by default; confirm LDAP_OPT_X_TLS_REQUIRE_CERT is actually enforcing, because a misconfigured StartTLS can fail open and leak credentials.
Error codes are your debugging index. Map them: 49 invalid credentials, 32 no such object (bad base DN or DN construction), 21 invalid syntax, 65 object class violation, 50 insufficient access, 53 unwilling to perform. A generic "LDAP error" with no code costs an hour; log ldap_errno()/ldap_error() on every failed operation.
No identity map, no change tracking. Unlike Doctrine, re-reading the same entity can hand you a different object. After a flush(), re-fetch if you need to display server-applied values. Don't assume two calls return the same instance.
Clear the metadata cache when you change entity annotations. It's a Symfony 2 bundle; it caches container/metadata aggressively. Half of "my new attribute is ignored" reports are stale cache. In a hand-wired setup, make sure you're not memoizing the metadata reader across a config change.
Referrals. Active Directory returns referrals that some naive clients chase in a loop. If a search against AD hangs or explodes, disable referral chasing (LDAP_OPT_REFERRALS off) and resolve targets explicitly.
Debugging recipe, in order: (1) reproduce with ldapsearch -x -LLL -D <binddn> -W -b <base> "<filter>"; (2) turn on LDAP tracing via ldap_set_option($conn, LDAP_OPT_DEBUG_LEVEL, 7); (3) log the compiled filter string and the resolved bind DN; (4) only then read the package source. Nine times out of ten the filter or the DN is wrong, not the ORM.
Extension points worth knowing: the repository base class (custom finders), the hydration layer (custom transformers for scalar/binary/multi-valued types), and the connection config (multiple named connections). Everything else — the bundle wiring, the DI extension — is throwaway the moment you change frameworks.
How can I help you explore Laravel packages today?