madewithlove/laravel-oauth2
An OAuth 2.0 authorization and resource server for Laravel, built on league/oauth2-server. It ships ready-made grant types, token repositories, middleware, and configuration to secure your API with access tokens, refresh tokens, and scopes.
The package is a thin Laravel wrapper around league/oauth2-server, so the first hour is mostly about getting the wrapper registered and the four OAuth tables seeded. Verify all class/config names against the published files — this predates modern package conventions and the README is thin.
Install (Laravel 4.x / early 5.x era constraints — see Gotchas):
composer require madewithlove/laravel-oauth2
Register the provider and aliases in the places your Laravel version expects (app/config/app.php on L4, config/app.php on L5):
'providers' => [
'Madewithlove\LaravelOauth2\OAuth2ServiceProvider',
],
'aliases' => [
'OAuth2Server' => 'Madewithlove\LaravelOauth2\Facades\OAuth2Server',
'ResourceServer' => 'Madewithlove\LaravelOauth2\Facades\ResourceServer',
],
Publish config + migrations, then migrate:
php artisan config:publish madewithlove/laravel-oauth2 # L4
php artisan vendor:publish --provider="Madewithlove\LaravelOauth2\OAuth2ServiceProvider"
php artisan migrate
Read these three things first, in order:
oauth2.php config — it defines which grants are enabled and how clients/tokens are stored.clients, access_tokens, refresh_tokens, and scopes tables.First use case — protect one existing API route, not the whole app:
Route::group(['prefix' => 'api', 'before' => 'oauth2'], function () {
Route::get('me', function () { return Auth::user(); });
});
Get a token in two commands so you know the machinery works before writing any grant code. Insert a client row manually (see Gotchas on column formats), then:
curl -X POST http://localhost/oauth/access-token \
-d grant_type=client_credentials \
-d client_id=1 -d client_secret=secret
You should get back access_token, token_type, expires_in. If you do, the rest is configuration.
One token endpoint, all grants. Route a single POST handler to the authorization server facade and let the grant_type parameter dispatch:
Route::post('oauth/access-token', function () {
return OAuth2Server::issueAccessToken();
});
This covers authorization code, password, client credentials, and refresh token simultaneously — the enabled grant list lives on the client row and in config, not on the route. Resist creating /oauth/token/password style routes; you'll duplicate validation and break refresh flows.
Scope enforcement as middleware parameters. Rather than checking scopes inside controllers, push it down onto the route:
Route::group(['before' => 'oauth2.scope:read'], function () {
Route::get('api/orders', 'Api\OrdersController@index');
});
Route::group(['before' => 'oauth2.scope:read,write'], function () {
Route::post('api/orders', 'Api\OrdersController@store');
});
The middleware does the 403 insufficient_scope response for you; controllers stay clean and testable.
Password grant = custom user resolution. The password grant needs to look up an owner by username and verify the password. The package delegates this. Hook it via the config callable or by implementing/binding the owner repository interface:
// config/oauth2.php (shape depends on version — confirm against source)
'password_grant' => [
'callback' => function ($username, $password) {
return Auth::validate(['email' => $username, 'password' => $password])
? User::where('email', $username)->first()
: null;
},
],
Keep this delegate thin — no side effects, no session writes. It's called on every token request and must be side-effect free or you'll get duplicate login events.
Custom storage = bind the interface, don't edit the package. Every repository (clients, access tokens, refresh tokens, scopes) is a container binding. For a multi-tenant setup, implement the repository interface for your tenant-aware model and rebind:
$this->app->bind(
'Madewithlove\LaravelOauth2\Repositories\ClientRepositoryInterface',
'App\OAuth\TenantClientRepository'
);
This is the main extension point and the only safe way to survive a package upgrade.
Token issuance from your own controllers. For first-party clients (mobile app, SPA) you often skip the HTTP round-trip and issue directly, returning the same JSON shape the endpoint would:
$token = OAuth2Server::issueAccessToken();
return Response::json($token)->header('Cache-Control', 'no-store');
Mirror the endpoint's headers exactly — clients written against RFC 6749 will expect them.
Refresh-token rotation on logout. There's no automatic revocation. On logout, explicitly expire the current access token and its refresh token:
OAuth2Server::getAccessToken()->expire();
OAuth2Server::getRefreshToken()->expire();
Third-party clients via authorization code. Seed client rows with a redirect_uri and the authorization_code grant, send users to your /oauth/authorize route, and validate redirect_uri against the stored value before redirecting — the league library does some of this, but confirm it, because a loose redirect check is the classic OAuth hole.
composer install or on runtime signature mismatches with league/oauth2-server. Budget a migration to laravel/passport or a direct league/oauth2-server integration before adopting this for new work.scopes table returns invalid_scope. Seed your scopes in a migration or seeder, and keep that list in version control — it's effectively part of your API contract.redirect_uri, grant_types, and scopes are stored in formats (serialized/JSON, depending on version) that a raw SQL insert will get wrong. Always create clients through Eloquent models or a seeder, never INSERT by hand.Cache-Control: no-store and Pragma: no-cache on token responses. The library adds these, but if you return tokens from your own controller (see above), you lose them — and proxies will happily cache a bearer token.date on any box that mysteriously rejects fresh tokens.invalid_client, invalid_grant, and invalid_request each point at entirely different mistakes and the response body won't tell you which field was wrong.oauth2 expects a Authorization: Bearer header and will 401 browser navigation. Keep it scoped to API route groups only.users table or a clients FK target, migrate breaks. Rename the published files to place them after your base tables.vendor/ — a composer update will silently revert your fix.curl-equivalent feature test that walks client credentials → token → protected route is the fastest way to catch config drift, and it's the test that will tell you whether a future migration off this package preserved behavior.How can I help you explore Laravel packages today?