Beartropy SAML2
Publish Login Listener
The listener is essential to handle what happens after a successful SAML authentication.
1php artisan saml2:publish-listener
Default Listener
This creates app/Listeners/HandleSaml2Login.php:
1 2namespace App\Listeners; 3 4use Beartropy\Saml2\Events\Saml2LoginEvent; 5use Illuminate\Support\Facades\Auth; 6use App\Models\User; 7 8class HandleSaml2Login 9{10 public function handle(Saml2LoginEvent $event): void11 {12 $email = $event->getEmail();13 $name = $event->getName();14 15 // Find or create user16 $user = User::firstOrCreate(17 ['email' => $email],18 ['name' => $name ?? $email]19 );20 21 // Authenticate user22 Auth::login($user, remember: true);23 }24}
Available Event Methods
The Saml2LoginEvent provides several helper methods to access SAML response data:
| Method | Return | Description |
|---|---|---|
| getEmail() | ?string | Get email from common attribute sources (mapped, raw, or NameID) |
| getName() | ?string | Get name from common attribute sources (displayName, cn) |
| getAttribute($key, $default) | mixed | Get a mapped attribute by key. Returns an array when the attribute has 2+ values (e.g. roles), a scalar when single-valued |
| getAttributeAll($key, $default = []) | array | Get a mapped attribute as an array of all its values (wraps a single scalar in one element). Use for roles/groups |
| getAttributes() | array | Get all mapped attributes |
| getRawAttribute($key, $default) | mixed | Get a raw SAML attribute by key. Returns only the first value |
| getRawAttributeAll($key, $default = []) | array | Get a raw SAML attribute as an array of all its values |
| getRawAttributes() | array | Get all raw SAML attributes as received |
| toArray() | array | Get all event data as an array |
Multi-valued attributes (roles & groups)
SAML attributes always arrive as arrays, and a user often belongs to several roles or groups. How you read them decides whether you get all the values or just one. Two things shape the return type:
- Mapped vs. raw.
getAttribute*read the mapped attributes (after yourattribute_mapping, keyed by your local key).getRawAttribute*read the untouched SAML claim, keyed by the exact claim name the IdP sent. - Single-value collapse. A single-valued attribute is stored as a scalar (so
email/namestay strings); an attribute with 2+ values is kept as an array.getAttribute()returns whatever was stored, so its type depends on the count.
| Accessor | Source | 0 values | 1 value | 2+ values |
|---|---|---|---|---|
| getAttribute('roles') | mapped | $default | 'admin' (scalar) | ['admin','editor'] |
| getAttributeAll('roles') | mapped | [] | ['admin'] | ['admin','editor'] |
| getRawAttribute('roles') | raw | $default | 'admin' | 'admin' (first only!) |
| getRawAttributeAll('roles') | raw | [] | ['admin'] | ['admin','editor'] |
getAttributeAll()/getRawAttributeAll()always return an array ([],['one'], or['many','…']). Use them whenever you iterate, count, orsyncRoles(), so the single-role case isn't a bare string.getAttribute()is a scalar for one value and an array for many — fine forsyncRoles()(spatie accepts either), but a footgun if youforeachit.getRawAttribute()returns only the first value — never use it for multi-valued claims.- The truly untouched raw array is
getRawAttributeAll()/getRawAttributes()[$key].
Public Properties
| Property | Type | Description |
|---|---|---|
| $idpKey | string | The key of the IDP that authenticated the user |
| $nameId | string | The SAML NameID (usually email or unique identifier) |
| $attributes | array | Mapped attributes based on config |
| $rawAttributes | array | Raw SAML attributes as received from IDP |
| $sessionIndex | ?string | The SAML session index for Single Logout (SLO) |
Customization Examples
Sync Roles from SAML
Sync roles using spatie/laravel-permission. Use getAttributeAll() so users with
two or more roles/groups keep all of them — getRawAttribute() returns only the first value:
1public function handle(Saml2LoginEvent $event): void 2{ 3 $email = $event->getEmail(); 4 $name = $event->getName(); 5 6 // getAttributeAll() always returns an array, so a user with two or more 7 // roles/groups keeps ALL of them. (getAttribute() returns a scalar for a 8 // single value and an array for multiple — getAttributeAll() is consistent.) 9 $groups = $event->getAttributeAll('groups');10 11 $user = User::firstOrCreate(12 ['email' => $email],13 ['name' => $name ?? $email]14 );15 16 // Sync roles (spatie/laravel-permission)17 if (!empty($groups)) {18 $user->syncRoles($groups);19 }20 21 Auth::login($user, remember: true);22}
Update User Data on Every Login
1public function handle(Saml2LoginEvent $event): void 2{ 3 $user = User::updateOrCreate( 4 ['email' => $event->getEmail()], 5 [ 6 'name' => $event->getName(), 7 'first_name' => $event->getAttribute('firstname'), 8 'last_name' => $event->getAttribute('lastname'), 9 'last_login_at' => now(),10 ]11 );12 13 Auth::login($user, remember: true);14}
Validate Email Domain
1public function handle(Saml2LoginEvent $event): void 2{ 3 $email = $event->getEmail(); 4 5 // Validate domain 6 if (!str_ends_with($email, '@your-company.com')) { 7 throw new \Exception('Email domain not allowed'); 8 } 9 10 $user = User::firstOrCreate(11 ['email' => $email],12 ['name' => $event->getName()]13 );14 15 Auth::login($user, remember: true);16}
Integrate SAML with Your Routes
Simple Login Link
1<a href="{{ route('saml2.login', ['idp' => 'azure']) }}">2 Login with Azure AD3</a>
Multiple IDPs
1<h3>Choose your identity provider:</h3>2<ul>3 <li><a href="{{ route('saml2.login', ['idp' => 'azure']) }}">Azure AD</a></li>4 <li><a href="{{ route('saml2.login', ['idp' => 'okta']) }}">Okta</a></li>5 <li><a href="{{ route('saml2.login', ['idp' => 'adfs']) }}">Corporate ADFS</a></li>6</ul>
Replacing Default Auth Routes
Example: Use local auth in development, SAML in production:
1// routes/auth.php 2 3Route::middleware('guest')->group(function () { 4 if (app()->environment('local')) { 5 // Local login for development 6 Route::get('/login', [AuthController::class, 'showLogin'])->name('login'); 7 Route::post('/login', [AuthController::class, 'authenticate']); 8 } else { 9 // SAML Login - redirects to IDP10 Route::get('/login', function () {11 return redirect()->route('saml2.login', ['idp' => 'azure']);12 })->name('login');13 }14});15 16Route::middleware('auth')->group(function () {17 if (app()->environment('local')) {18 Route::post('/logout', [AuthController::class, 'logout'])->name('logout');19 } else {20 // SAML Logout (SLO)21 Route::match(['get', 'post'], '/logout', function () {22 return redirect()->route('saml2.logout');23 })->name('logout');24 }25});
Post-Setup Checklist
- ☐ SP Entity ID configured in
.env - ☐ IDP created and active
- ☐ Login listener published and customized
- ☐ Login/logout routes integrated into your app
- ☐ SP certificates generated (production)
- ☐ Admin middleware configured (optional)
- ☐ Attribute mapping configured (if needed)
- ☐ Tested complete login flow