ismailelbery / laravel-ldap-sync
Sync users from Active Directory OUs into a local Eloquent model, with live LDAP search and manager resolution.
Requires
- php: ^8.2
- directorytree/ldaprecord-laravel: ^3.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- laravel/pint: ^1.13
- orchestra/testbench: ^9.0
- pestphp/pest: ^2.0
- pestphp/pest-plugin-laravel: ^2.0
README
An opinionated LDAP / Active Directory user synchronization package for Laravel. Sync users from multiple Organizational Units (OUs) into your local database on a schedule, with department mapping, attribute transformation, and full sync reporting β built on top of LdapRecord.
Designed for enterprise and government environments where Active Directory is the source of truth and your Laravel application needs a reliable, queryable local copy of users.
β¨ Features
- π Scheduled synchronization β run full or incremental syncs via Laravel's scheduler
- π’ Multi-OU support β search and sync across multiple base DNs / OUs in a single run
- π User search β search LDAP directly by username, name (EN/AR), email, or department with a fluent API
- π Manager resolution β get a user's direct manager or the full management chain up to the top
- ποΈ Department mapping β map LDAP OUs or attributes to local departments/teams
- π§ Attribute mapping β declarative config to map LDAP attributes to Eloquent columns (with custom transformers)
- π Arabic / UTF-8 safe β correct handling of Arabic display names and attributes
- π§Ή Stale user handling β disable, soft-delete, or flag users removed from AD
- π Sync reports β created / updated / disabled counts, with optional logging and notifications
- π§ͺ Dry-run mode β preview changes before touching your database
- πͺ Events β hook into
UserSynced,UserDisabled,SyncCompletedfor custom logic
π¦ Installation
composer require ismailelbery/laravel-ldap-sync
Publish the config and migrations:
php artisan vendor:publish --tag="ldap-sync-config" php artisan vendor:publish --tag="ldap-sync-migrations" php artisan migrate
βοΈ Configuration
All connection settings live in your .env:
LDAP_HOST=ad.example.gov.sa LDAP_PORT=389 LDAP_USERNAME="cn=svc-laravel,ou=ServiceAccounts,dc=example,dc=gov,dc=sa" LDAP_PASSWORD=secret LDAP_BASE_DN="dc=example,dc=gov,dc=sa" # Semicolon-separated list of OUs to sync (relative or absolute DNs) LDAP_SYNC_OUS="ou=IT,ou=Departments;ou=HR,ou=Departments;ou=Digitization" LDAP_SYNC_SCHEDULE="0 2 * * *" # nightly at 2 AM LDAP_SYNC_STALE_STRATEGY=disable # disable | delete | flag | ignore
The published config/ldap-sync.php lets you define attribute mapping:
return [ 'model' => App\Models\User::class, 'unique_key' => [ 'ldap' => 'objectguid', 'local' => 'ldap_guid', ], 'attributes' => [ 'samaccountname' => 'username', 'mail' => 'email', 'displayname' => 'name', 'displayname_ar' => 'name_ar', // custom AD attribute 'telephonenumber' => 'phone', 'department' => 'department_name', 'title' => 'job_title', ], // Optional: transform values before saving 'transformers' => [ 'phone' => fn ($value) => preg_replace('/^\+?966/', '0', $value ?? ''), ], // Map OUs to local department IDs 'department_map' => [ 'ou=Digitization' => 'General Department of Digitization & AI', 'ou=HR' => 'Human Resources', ], 'stale_users' => [ 'strategy' => env('LDAP_SYNC_STALE_STRATEGY', 'disable'), 'column' => 'is_active', ], ];
π Usage
Run a sync manually
php artisan ldap:sync
Sync a specific OU only
php artisan ldap:sync --ou="ou=IT,ou=Departments"
Preview changes without writing (dry run)
php artisan ldap:sync --dry-run
Example output:
Syncing 3 OUs from ad.example.gov.sa...
β ou=IT,ou=Departments ............ 142 found
β ou=HR,ou=Departments ............ 38 found
β ou=Digitization ................. 27 found
Created: 12
Updated: 189
Disabled: 6
Skipped: 0
Sync completed in 4.2s
Schedule it
In routes/console.php (Laravel 11):
use Illuminate\Support\Facades\Schedule; Schedule::command('ldap:sync')->dailyAt('02:00');
Listen to events
use IsmailElbery\LdapSync\Events\UserSynced; use IsmailElbery\LdapSync\Events\SyncCompleted; Event::listen(UserSynced::class, function (UserSynced $event) { // $event->user, $event->wasRecentlyCreated }); Event::listen(SyncCompleted::class, function (SyncCompleted $event) { // $event->report->created, ->updated, ->disabled });
π User Search
Search Active Directory directly (live, not the local table) using the LdapUserSearch facade. All searches run across all configured OUs and merge + deduplicate results by objectguid.
use IsmailElbery\LdapSync\Facades\LdapUserSearch; // By username (sAMAccountName) β exact match $user = LdapUserSearch::byUsername('i.elbery'); // By email β exact match $user = LdapUserSearch::byEmail('i.elbery@example.gov.sa'); // By name β partial match, searches displayName (EN and AR attributes) $users = LdapUserSearch::byName('Ismail'); // returns Collection $users = LdapUserSearch::byName('Ψ₯Ψ³Ω Ψ§ΨΉΩΩ'); // Arabic works too // By department $users = LdapUserSearch::byDepartment('Digitization'); // Smart search β detects whether the term looks like an email, // username, or name, and searches the right attributes $users = LdapUserSearch::search('i.elbery@example.gov.sa'); $users = LdapUserSearch::search('Ismail'); // Fluent combination $users = LdapUserSearch::query() ->inOu('ou=IT,ou=Departments') ->department('Infrastructure') ->name('Ahmed') ->limit(20) ->get();
Each result is returned as an LdapUserDto with a consistent shape regardless of AD schema quirks:
$user->username; // sAMAccountName $user->name; // displayName $user->nameAr; // Arabic display name (if configured) $user->email; $user->department; $user->title; $user->phone; $user->dn; // distinguished name $user->guid; // objectGUID $user->managerDn; // raw manager attribute
Search command
A quick CLI lookup is also included:
php artisan ldap:search "Ismail"
php artisan ldap:search --email=i.elbery@example.gov.sa
php artisan ldap:search --department=Digitization
π Manager Resolution
Resolve reporting lines straight from the AD manager attribute.
use IsmailElbery\LdapSync\Facades\LdapUserSearch; $user = LdapUserSearch::byUsername('i.elbery'); // Direct manager β single LdapUserDto (or null) $manager = LdapUserSearch::directManagerOf($user); $manager = LdapUserSearch::directManagerOf('i.elbery'); // username works too // Full management chain β ordered Collection from direct manager // up to the top of the hierarchy (cycle-safe) $chain = LdapUserSearch::managersOf('i.elbery'); foreach ($chain as $level => $manager) { echo "{$level}: {$manager->name} ({$manager->title})"; } // 0: Ahmed Saleh (Section Head) // 1: Khalid Omar (Department Director) // 2: Fahad Al-Otaibi (Deputy Governor) // Limit chain depth (e.g. for approval workflows needing only 2 levels) $chain = LdapUserSearch::managersOf('i.elbery', maxDepth: 2); // Inverse: who reports directly to this user? $reports = LdapUserSearch::directReportsOf('k.omar');
This is particularly useful for approval workflows β route a request to a user's direct manager, or escalate up the chain automatically:
$approver = LdapUserSearch::directManagerOf(auth()->user()->username); ApprovalRequest::create([ 'requester_id' => auth()->id(), 'approver_dn' => $approver?->dn, ]);
Note: Manager resolution requires the
managerattribute to be populated in your AD. The chain resolver guards against circular references and missing managers, returning the chain built so far rather than throwing.
π§± How It Works
- Connects to your LDAP/AD server using LdapRecord with the credentials in
config/ldap.php. - Iterates each configured OU, paginating results to safely handle large directories.
- For each entry, resolves the local user via the configured unique key (
objectguidby default β immune to renames and OU moves). - Applies attribute mapping and transformers, then creates or updates the Eloquent model.
- After all OUs are processed, applies the stale-user strategy to local users no longer present in AD.
- Fires events and writes a sync report.
π§ͺ Testing
composer test
The test suite uses LdapRecord's built-in directory emulator, so no real LDAP server is required.
πΊοΈ Roadmap
- Group β role synchronization (Spatie permissions bridge)
- Incremental sync via
whenChanged/ USN tracking - Photo (
thumbnailPhoto) sync to local storage - Web dashboard for sync history
π€ Contributing
Contributions are welcome! Please open an issue first to discuss what you would like to change, and make sure tests pass before submitting a PR.
π Security
If you discover a security vulnerability, please email ismail.bery@gmail.com instead of using the issue tracker.
π License
The MIT License (MIT). See LICENSE.md for details.
Built with β by Ismail Elbery β born from years of syncing Active Directory users in Laravel government systems.