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.
Step 1: Installing Laravel
I started by creating a new project folder:
mkdir admiral-laravel-init && cd admiral-laravel-init
Next, I installed Laravel globally:
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
Once the dev server starts, it prints the APP_URL. For me, it was:
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
During setup, I picked:
“Install the template without backend setting”,
and for the project name, I enteredadmin
.
That gave me a new directory: admiral-laravel-init/admin
. I jumped into it and installed dependencies:
cd admin && npm i
Then I updated the .env file to point to the Laravel backend:
VITE_API_URL=http://localhost:8000/admin
Now I built and started the Admiral frontend:
npm run build && npm run dev
Once the dev server was up, I saw this in the terminal:
Local: http://localhost:3000/
Opening that URL showed the /login
page. Perfect.
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.
Install Sanctum
First, I installed Laravel Sanctum:
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',
],
],
Next, I added the HasApiTokens
trait to the User
model:
class User extends Authenticatable
{
use HasFactory, Notifiable, HasApiTokens;
}
AuthController.php
Now it was time to create the actual AuthController:
<?php
namespace AppHttpControllers;
use IlluminateHttpRequest;
use AppHttpRequestsLoginRequest;
use AppServicesAdminAuthAuthService;
use IlluminateValidationValidationException;
use AppHttpResourcesAuthUserResource;
use AppServicesAdminAuthLimitLoginAttempts;
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): IlluminateHttpJsonResponse
{
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 AppHttpRequests;
use IlluminateFoundationHttpFormRequest;
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 AppHttpResources;
use IlluminateHttpResourcesJsonJsonResource;
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
.
AuthService.php
<?php
declare(strict_types = 1);
namespace AppServicesAdminAuth;
use AppModelsUser;
use IlluminateSupportFacadesHash;
use IlluminateValidationValidationException;
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 AppServicesAdminAuth;
use IlluminateAuthEventsLockout;
use IlluminateCacheRateLimiter;
use IlluminateHttpRequest;
use IlluminateSupportStr;
use IlluminateValidationValidationException;
use SymfonyComponentHttpFoundationResponse;
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 IlluminateSupportFacadesRoute;
use AppHttpControllersAuthController;
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
:
Route::middleware('admin')
->prefix('admin')
->group(base_path('routes/admin.php'));
Add a Seed User
Update database/seeders/DatabaseSeeder.php
:
use AppModelsUser;
use IlluminateDatabaseSeeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
User::factory()->create([
'name' => 'Test User',
'email' => '[email protected]',
'password' => '12345678',
]);
}
}
Then run:
php artisan db:seed
composer run dev
Login using the seeded credentials. If you hit a CORS issue, run:
php artisan config:publish cors
Then update config/cors.php
:
'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.
Questions? Thoughts? I’m all ears — ping me on GitHub or drop an issue on Admiral.