You know the drill. Spin up Laravel, glue on a frontend, duct-tape together some authentication, and pretend the repetition isn’t driving you insane. Most admin panels are the same—auth, a few routes, a form or two, maybe a table. And yet, somehow, I always catch myself wasting half a day rebuilding the same damn scaffolding I built last week. That’s what pushed me to build Admiral — an open-source admin panel boilerplate that plays nicely with Laravel and skips the tedium. You can check it out here, but what I really want to do is walk you through a real-world setup: Laravel + Admiral with authentication using Sanctum. Minimal ceremony, just a working setup that gets out of your way so you can ship features. here Step 1: Installing Laravel I started by creating a new project folder: mkdir admiral-laravel-init && cd admiral-laravel-init mkdir admiral-laravel-init && cd admiral-laravel-init Next, I installed Laravel globally: composer global require laravel/installer composer global require laravel/installer Then I created a new Laravel app in a backend directory. I went with SQLite for simplicity, but feel free to use MySQL, Postgres, or whatever suits you. To verify things are working, I ran: cd backend && composer run dev cd backend && composer run dev Once the dev server starts, it prints the APP_URL. For me, it was: APP_URL: http://localhost:8000 APP_URL: http://localhost:8000 Opening that in a browser confirmed Laravel was up and running. Step 2: Installing Admiral To bootstrap the admin panel, I ran: npx create-admiral-app@latest npx create-admiral-app@latest During setup, I picked:“Install the template without backend setting”,and for the project name, I enteredadmin. admin That gave me a new directory: admiral-laravel-init/admin. I jumped into it and installed dependencies: admiral-laravel-init/admin cd admin && npm i cd admin && npm i Then I updated the .env file to point to the Laravel backend: VITE_API_URL=http://localhost:8000/admin VITE_API_URL=http://localhost:8000/admin Now I built and started the Admiral frontend: npm run build && npm run dev npm run build && npm run dev Once the dev server was up, I saw this in the terminal: Local: http://localhost:3000/ Local: http://localhost:3000/ Opening that URL showed the /login page. Perfect. /login Step 3: Setting Up Authentication With both Admiral and Laravel live, it was time to wire up authentication using Laravel Sanctum and Admiral’s AuthProvider interface. AuthProvider Install Sanctum First, I installed Laravel Sanctum: php artisan install:api php artisan install:api Then I opened config/auth.php and registered a new admin guard: 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], 'admin' => [ 'driver' => 'sanctum', 'provider' => 'users', ], ], 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], 'admin' => [ 'driver' => 'sanctum', 'provider' => 'users', ], ], Next, I added the HasApiTokens trait to the User model: HasApiTokens User class User extends Authenticatable { use HasFactory, Notifiable, HasApiTokens; } class User extends Authenticatable { use HasFactory, Notifiable, HasApiTokens; } AuthController.php Now it was time to create the actual AuthController: <?php namespace App\Http\Controllers; use Illuminate\Http\Request; use App\Http\Requests\LoginRequest; use App\Services\Admin\Auth\AuthService; use Illuminate\Validation\ValidationException; use App\Http\Resources\AuthUserResource; use App\Services\Admin\Auth\LimitLoginAttempts; class AuthController { use LimitLoginAttempts; public function __construct( private readonly AuthService $auth, ) { } public function getIdentity(Request $request): array { $user = $request->user(); return [ 'user' => AuthUserResource::make($user), ]; } public function checkAuth(Request $request): \Illuminate\Http\JsonResponse { return response()->json('ok', 200); } public function logout(Request $request): void { $request->user()->currentAccessToken()->delete(); } public function login(LoginRequest $request): array { if ($this->hasTooManyLoginAttempts($request)) { $this->fireLockoutEvent($request); $this->sendLockoutResponse($request); } try { $user = $this->auth->login($request->email(), $request->password()); } catch (ValidationException $e) { $this->incrementLoginAttempts($request); throw $e; } catch (\Throwable $e) { $this->incrementLoginAttempts($request); throw ValidationException::withMessages([ 'email' => [__('auth.failed')], ]); } $token = $user->createToken('admin'); return [ 'user' => AuthUserResource::make($user), 'token' => $token->plainTextToken, ]; } } <?php namespace App\Http\Controllers; use Illuminate\Http\Request; use App\Http\Requests\LoginRequest; use App\Services\Admin\Auth\AuthService; use Illuminate\Validation\ValidationException; use App\Http\Resources\AuthUserResource; use App\Services\Admin\Auth\LimitLoginAttempts; class AuthController { use LimitLoginAttempts; public function __construct( private readonly AuthService $auth, ) { } public function getIdentity(Request $request): array { $user = $request->user(); return [ 'user' => AuthUserResource::make($user), ]; } public function checkAuth(Request $request): \Illuminate\Http\JsonResponse { return response()->json('ok', 200); } public function logout(Request $request): void { $request->user()->currentAccessToken()->delete(); } public function login(LoginRequest $request): array { if ($this->hasTooManyLoginAttempts($request)) { $this->fireLockoutEvent($request); $this->sendLockoutResponse($request); } try { $user = $this->auth->login($request->email(), $request->password()); } catch (ValidationException $e) { $this->incrementLoginAttempts($request); throw $e; } catch (\Throwable $e) { $this->incrementLoginAttempts($request); throw ValidationException::withMessages([ 'email' => [__('auth.failed')], ]); } $token = $user->createToken('admin'); return [ 'user' => AuthUserResource::make($user), 'token' => $token->plainTextToken, ]; } } Supporting Files LoginRequest.php <?php declare(strict_types=1); namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; final class LoginRequest extends FormRequest { public function rules(): array { return [ 'email' => ['required', 'email'], 'password' => ['required'], ]; } public function email(): string { return $this->input('email'); } public function password(): string { return $this->input('password'); } } <?php declare(strict_types=1); namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; final class LoginRequest extends FormRequest { public function rules(): array { return [ 'email' => ['required', 'email'], 'password' => ['required'], ]; } public function email(): string { return $this->input('email'); } public function password(): string { return $this->input('password'); } } AuthUserResource.php <?php namespace App\Http\Resources; use Illuminate\Http\Resources\Json\JsonResource; class AuthUserResource extends JsonResource { public function toArray($request): array { $this->resource = [ 'id' => $this->resource->id, 'name' => $this->resource->name, 'email' => $this->resource->email, ]; return parent::toArray($request); } } <?php namespace App\Http\Resources; use Illuminate\Http\Resources\Json\JsonResource; class AuthUserResource extends JsonResource { public function toArray($request): array { $this->resource = [ 'id' => $this->resource->id, 'name' => $this->resource->name, 'email' => $this->resource->email, ]; return parent::toArray($request); } } Step 4: The Authentication Service Here’s how I structured my backend logic: services → admin → auth. services → admin → auth AuthService.php <?php declare(strict_types = 1); namespace App\Services\Admin\Auth; use App\Models\User; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\ValidationException; final class AuthService { public function __construct() { } public function login(string $email, string $password): User { $user = $this->findByEmail($email); throw_if( !$user || !Hash::check($password, $user->password), ValidationException::withMessages([ 'password' => __('auth.failed'), ]) ); return $user; } public function findByEmail(string $email): User|null { return User::query()->where('email', $email)->first(); } } <?php declare(strict_types = 1); namespace App\Services\Admin\Auth; use App\Models\User; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\ValidationException; final class AuthService { public function __construct() { } public function login(string $email, string $password): User { $user = $this->findByEmail($email); throw_if( !$user || !Hash::check($password, $user->password), ValidationException::withMessages([ 'password' => __('auth.failed'), ]) ); return $user; } public function findByEmail(string $email): User|null { return User::query()->where('email', $email)->first(); } } LimitLoginAttempts.php <?php declare(strict_types=1); namespace App\Services\Admin\Auth; use Illuminate\Auth\Events\Lockout; use Illuminate\Cache\RateLimiter; use Illuminate\Http\Request; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpFoundation\Response; trait LimitLoginAttempts { public function maxAttempts(): int { return property_exists($this, 'maxAttempts') ? $this->maxAttempts : 5; } public function decayMinutes(): int { return property_exists($this, 'decayMinutes') ? $this->decayMinutes : 1; } protected function hasTooManyLoginAttempts(Request $request): bool { return $this->limiter()->tooManyAttempts( $this->throttleKey($request), $this->maxAttempts() ); } protected function incrementLoginAttempts(Request $request): void { $this->limiter()->hit( $this->throttleKey($request), $this->decayMinutes() * 60 ); } protected function sendLockoutResponse(Request $request): void { $seconds = $this->limiter()->availableIn( $this->throttleKey($request) ); throw ValidationException::withMessages([ $this->loginKey() => [__('auth.throttle', [ 'seconds' => $seconds, 'minutes' => ceil($seconds / 60), ])], ])->status(Response::HTTP_TOO_MANY_REQUESTS); } protected function clearLoginAttempts(Request $request): void { $this->limiter()->clear($this->throttleKey($request)); } protected function limiter(): RateLimiter { return app(RateLimiter::class); } protected function fireLockoutEvent(Request $request): void { event(new Lockout($request)); } protected function throttleKey(Request $request): string { return Str::transliterate(Str::lower($request->input($this->loginKey())) . '|' . $request->ip()); } protected function loginKey(): string { return 'email'; } } <?php declare(strict_types=1); namespace App\Services\Admin\Auth; use Illuminate\Auth\Events\Lockout; use Illuminate\Cache\RateLimiter; use Illuminate\Http\Request; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpFoundation\Response; trait LimitLoginAttempts { public function maxAttempts(): int { return property_exists($this, 'maxAttempts') ? $this->maxAttempts : 5; } public function decayMinutes(): int { return property_exists($this, 'decayMinutes') ? $this->decayMinutes : 1; } protected function hasTooManyLoginAttempts(Request $request): bool { return $this->limiter()->tooManyAttempts( $this->throttleKey($request), $this->maxAttempts() ); } protected function incrementLoginAttempts(Request $request): void { $this->limiter()->hit( $this->throttleKey($request), $this->decayMinutes() * 60 ); } protected function sendLockoutResponse(Request $request): void { $seconds = $this->limiter()->availableIn( $this->throttleKey($request) ); throw ValidationException::withMessages([ $this->loginKey() => [__('auth.throttle', [ 'seconds' => $seconds, 'minutes' => ceil($seconds / 60), ])], ])->status(Response::HTTP_TOO_MANY_REQUESTS); } protected function clearLoginAttempts(Request $request): void { $this->limiter()->clear($this->throttleKey($request)); } protected function limiter(): RateLimiter { return app(RateLimiter::class); } protected function fireLockoutEvent(Request $request): void { event(new Lockout($request)); } protected function throttleKey(Request $request): string { return Str::transliterate(Str::lower($request->input($this->loginKey())) . '|' . $request->ip()); } protected function loginKey(): string { return 'email'; } } Step 5: Routes + Seeding routes/admin.php <?php declare(strict_types = 1); use Illuminate\Support\Facades\Route; use App\Http\Controllers\AuthController; Route::group(['prefix' => 'auth'], function () { Route::post('login', [AuthController::class, 'login'])->name('login'); Route::group(['middleware' => ['auth:admin']], function () { Route::post('logout', [AuthController::class, 'logout']); Route::get('/get-identity', [AuthController::class, 'getIdentity']); Route::get('/check-auth', [AuthController::class, 'checkAuth']); }); }); <?php declare(strict_types = 1); use Illuminate\Support\Facades\Route; use App\Http\Controllers\AuthController; Route::group(['prefix' => 'auth'], function () { Route::post('login', [AuthController::class, 'login'])->name('login'); Route::group(['middleware' => ['auth:admin']], function () { Route::post('logout', [AuthController::class, 'logout']); Route::get('/get-identity', [AuthController::class, 'getIdentity']); Route::get('/check-auth', [AuthController::class, 'checkAuth']); }); }); Then I registered it inside bootstrap/app.php: bootstrap/app.php Route::middleware('admin') ->prefix('admin') ->group(base_path('routes/admin.php')); Route::middleware('admin') ->prefix('admin') ->group(base_path('routes/admin.php')); Add a Seed User Update database/seeders/DatabaseSeeder.php: database/seeders/DatabaseSeeder.php use App\Models\User; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { public function run(): void { User::factory()->create([ 'name' => 'Test User', 'email' => 'test@example.com', 'password' => '12345678', ]); } } use App\Models\User; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { public function run(): void { User::factory()->create([ 'name' => 'Test User', 'email' => 'test@example.com', 'password' => '12345678', ]); } } Then run: php artisan db:seed composer run dev php artisan db:seed composer run dev Login using the seeded credentials. If you hit a CORS issue, run: php artisan config:publish cors php artisan config:publish cors Then update config/cors.php: config/cors.php 'paths' => ['api/*', 'sanctum/csrf-cookie', 'admin/*'], 'paths' => ['api/*', 'sanctum/csrf-cookie', 'admin/*'], You're Done At this point, I had a fully functional Laravel + Admiral stack with token-based auth, rate limiting, and frontend integration. If you made it this far, you’re ready to move on to CRUDs, tables, dashboards, and everything else. That’s for the next article. That’s for the next article. Questions? Thoughts? I'm all ears — ping me on GitHub or drop an issue on Admiral. Admiral