Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Entra Laravel Package

networkrailbusinesssystems/entra

Laravel integration for Microsoft Entra ID authentication, offering streamlined SSO configuration, user synchronization, and role mapping so PHP applications can securely connect to Entra with minimal setup.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and publish.

composer require networkrailbusinesssystems/entra
php artisan vendor:publish --provider="NetworkRailBusinessSystems\Entra\EntraServiceProvider"
php artisan migrate

Publishing should give you a config file (typically config/entra.php) and a migration adding the Entra identifier columns to users (usually entra_id / object_id plus optionally tenant_id). Read the config file top to bottom before touching anything else — it is the single best map of what the package actually does, better than the README for edge-case behaviour.

Register the Azure app first. The package can't do anything until an app registration exists in the Microsoft Entra admin centre:

  1. App registrations → New registration, choose Accounts in this organizational directory only (single-tenant) unless you genuinely need external users.
  2. Add a Web redirect URI pointing at the package's callback route (check routes/ in the package, commonly https://your-app.test/auth/entra/callback). It must match byte-for-byte.
  3. Create a client secret and note the value immediately — it is hidden afterwards.
  4. API permissions → Microsoft Graph → delegated openid, profile, email, User.Read for sign-in, and application User.Read.All, Group.Read.All, GroupMember.Read.All if you intend to poll users and groups. Then click Grant admin consent — without it the polling half of the package will return 403 and look broken.

Environment. Fill in the usual four values before your first request:

ENTRA_CLIENT_ID=00000000-0000-0000-0000-000000000000
ENTRA_CLIENT_SECRET=...
ENTRA_TENANT_ID=00000000-0000-0000-0000-000000000000
ENTRA_REDIRECT_URI="${APP_URL}/auth/entra/callback"

Then php artisan config:clear — the most common "it ignores my env" cause in Laravel is a cached config from a previous deploy.

First use case. Don't start with group syncing. Start with the smallest possible loop: add the package's login route to a test page, sign in as yourself, and inspect the local users row that gets created or matched. That confirms tenant, secret, redirect URI, and claim mapping in one go. Only once a real user lands in your database correctly should you wire up group polling — debugging two OAuth/Graph problems at once is miserable.

Implementation Patterns

SSO as a guard, not a bolted-on controller. The package is designed so Entra becomes your authentication driver rather than a side-channel. Protect routes with the standard middleware and let the package own the redirect/callback pair:

Route::middleware('auth')->group(function () {
    Route::get('/dashboard', DashboardController::class);
});

// Public entry point that kicks off the OAuth dance
Route::get('/login', fn () => redirect()->route('entra.redirect'));

Keep a local users row as the canonical identity for the rest of your app. Everything else — policies, Auth::id(), relations — should keep working unchanged; the Entra id is just an additional column and a lookup key.

Just-in-time provisioning on login. The lowest-friction pattern is: on callback, match on entra_id first, fall back to email/UPN, then create. This means nobody needs a manual invite step, and existing users migrate cleanly the first time they sign in. Guard the fallback match carefully — if your app allows local password accounts, an email collision is an account-takeover vector.

Reconcile rather than overwrite. Treat Entra as the source of truth for attributes (display name, job title, department, manager) but your database as the source of truth for application state (which roles they hold, what they own). Write a small EntraUserReconciler service rather than scattering updateOrCreate calls through controllers:

public function reconcile(EntraUser $remote): User
{
    return User::updateOrCreate(
        ['entra_id' => $remote->id],
        ['name' => $remote->displayName, 'email' => $remote->mail],
    );
}

Poll groups on a schedule, not on login. Group membership is expensive to fetch and rarely changes minute to minute. The idiomatic setup is a scheduled command plus a pivot table:

// app/Console/Kernel.php
$schedule->command('entra:sync-groups')->hourly()->withoutOverlapping();

withoutOverlapping() is not optional — a slow Graph response will otherwise queue a second run on top of the first and you'll get interleaved writes.

Use groups for authorisation via a single gate hook. Rather than in_array('App Admins', $user->entraGroups) sprinkled through controllers, centralise it:

Gate::before(function (User $user, string $ability) {
    return $user->hasEntraGroup(config("permissions.$ability"))
        ? true
        : null; // null = fall through to policies
});

Returning null (not false) is important — it lets normal policies still run.

Integration with existing auth. If you're adding Entra to an app that already has logins, run it as an additional driver and let users link accounts from a settings page, keyed on entra_id. Forcing a hard cutover on a Friday is how you discover half your service accounts aren't in Entra.

Testing. Never let tests touch Graph. The package will use Laravel's HTTP client under the hood, so Http::fake() with a captured Graph JSON fixture is enough for the polling paths, and actingAs($user) covers everything downstream of login. Capture one real /users and one /groups/{id}/members response and commit them as fixtures — Graph's payload shape has enough nesting (@odata.nextLink, value) that hand-written fakes drift.

Gotchas and Tips

Group claims in the token are capped. Entra emits group IDs in the token only if you configure it, and silently switches to an overage claim once a user exceeds roughly 200 groups. If your authorisation logic reads groups from the token, it will work for everyone in small teams and fail for exactly the executives and admins who belong to the most groups. Read from the polled local tables instead, and treat the token as identity only.

Graph throttles aggressively. Bulk polling will hit 429s. Honour the Retry-After header rather than a fixed sleep, use $top pagination with @odata.nextLink, and prefer delta queries (/users/delta) for repeat syncs — it reduces a 10,000-user fetch to a handful of changed records. For very large tenants, chunk the work into queued jobs per page so a single timeout doesn't discard an hour of progress.

Client secrets expire. Default lifetime is 24 months and nothing warns you in-app — you get a sudden 401 at 3am. Put the expiry date in your runbook, or move to certificate credentials / workload identity federation, which the package accepts if it exposes a credential config key. Check the config file for whether a certificate path is supported before assuming secret-only.

Tenant value matters. common allows any Microsoft account including personal ones; organizations allows any work account; a raw tenant GUID restricts to yours. This is a one-line change with a security-sized blast radius, and it's easy to inherit common from a copy-pasted tutorial.

Redirect URI mismatches fail opaquely. Azure returns AADSTS50011 and Laravel logs nothing useful. The value in ENTRA_REDIRECT_URI must match the portal exactly — scheme, host, port, trailing slash. http://localhost vs http://127.0.0.1 are different registrations.

UPN is not email. userPrincipalName can differ from mail for guest/B2B users and for accounts with unusual domain setups. mail can be null for accounts without Exchange. Decide which one you key on and normalise case (strtolower) before matching, or you'll get duplicate local users.

Don't delete local users on absence. A Graph hiccup, a permissions change, or a user temporarily moved to a different OU will all make someone vanish from a sync response. Soft-delete and deactivate; let a human confirm hard deletion. Log every deactivation with the timestamp of the sync run that caused it.

Guest and disabled accounts. Disabled accounts still appear in /users unless you filter accountEnabled eq true. Guests appear with a #EXT# UPN. Both will pollute naive counts and, worse, can pass a membership check.

Debugging checklist, in order: config('entra') actually populated → token acquired (Http::fake a token endpoint if unsure) → Graph returns 200 for /users?$top=1 with your app credentials → only then look at your own mapping code. Nine times out of ten the failure is admin consent, not PHP.

Extension point. If the package's default mapping doesn't fit, don't fork it — listen for whatever events it fires on login and sync, or wrap the sync command in your own command that calls the package's service and post-processes. Check whether user creation is bound in the service provider; if it is, a container rebind gives you a clean override with no vendor edits.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core