Back to Blog

Initializing Laravel and Admiral: Authentication Setup

Dima Kasperovich - dev.family
Dima Kasperovich
lead backend developer

Jul 16, 2025

10 minutes reading

Initializing Laravel and Admiral: Authentication Setup - dev.family

Most admin panels built with Laravel look the same: authentication, routing, a couple of forms, a table or two. And yet every time, we end up writing the same boilerplate – burning hours not on business logic, but on setting up infrastructure that barely changes from project to project.

The worst part? Manually wiring up the frontend and backend – configuring auth flows, environment settings, and directory structure – again and again.

That’s why we’ve built Admiral – an open source framework for admin panel development. It cuts the boilerplate, enforces a clear standard, and gives you a clean starting point that integrates seamlessly with Laravel. It’s fast to spin up, flexible to extend, and lets you focus on building product logic – not reinventing the scaffolding.

You can meet Admiral here: https://github.com/dev-family/admiral.

In this guide, I’ll walk you through setting up a fully working Laravel + Admiral stack from scratch, including authentication via Laravel Sanctum. The goal is to get to a place where structure is out of the way, and you can just ship features.

Installing Laravel

Let’s start by creating a base project directory – admiral-laravel-init – and navigating into it.

We'll install the latest version of Laravel, which at the time of writing is version 12. Inside admiral-laravel-init run the command from official documentation – composer global require laravel/installer. 

Next, create a new Laravel project by running the command laravel-new-backend, where backend is our Laravel directory. For the database, we’ll go with the simplest option – SQLite. But feel free to use whichever DB suits your workflow.

Now go into the backend directory and start the Laravel dev server with the command: composer run dev. 

Once the server starts, you’ll see the APP_URL printed in the console. In my case, it was: APP_URLhttp://localhost:8000

Open that URL in your browser to confirm that Laravel is running properly.

<b>Installing Laravel</b>

Installing Admiral

To initialize the admin panel, run the following command: npx create-admiral-app@latest

During setup, select the option Install the template without backend setting, and when prompted for the project name, enter: admin.

Once the setup is complete, the admin panel will be installed at admiral-laravel-init/admin. Navigate into the admin directory and install the project dependencies with the command: npm i .

Next, update the .env file and set the backend URL to point to your Laravel server. In my case, it looks like this: VITE_API_URL=http://localhost:8000/admin

You can now build and start the Admiral frontend by running the command: npm run build && npm run dev. 

Once the dev server is up, you’ll see a success message in the terminal. In my case it’s Localhttp://localhost:3000/.

If you open that URL in the browser, you’ll be redirected to the /login page.

Installing Admiral

Authentication

Now that both Admiral and Laravel are up and running, we can move on to implementing basic authentication. We’ll rely on Admiral’s AuthProvider interface and implement the required methods – you can find the official documentation. 

Before we dive into the logic, let’s take care of a few setup steps. We’ll be using Laravel’s built-in solution – Sanctum – for authentication. Install it by running:  php artisan install:api.

Next, open config/auth.php and register a new guard named admin, using the sanctum driver:

'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        'admin' => [
            'driver' => 'sanctum',
            'provider' => 'users',
        ],
    ],

We also need to add the HasApiTokens trait to the User model — this enables API token handling and is required for Sanctum to function correctly.

class User extends Authenticatable
{
    /** @use HasFactory<\Database\Factories\UserFactory> */
    use HasFactory, Notifiable, HasApiTokens;

Now we can move on to creating the AuthController with the following implementation.

AuthController.php

<?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,
        ];
    }
}

For convenience, we’ll also create the corresponding Request and Resource classes.

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');
    }
}

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);
    }
}

Now let’s move on to building the actual authentication service. We recommend organizing the structure as follows: services → admin → auth.

You’re free to structure it differently, but in our experience, this layout works best – it follows the separation of concerns principle and keeps admin-specific logic neatly isolated on its own.

The same applies to the previously created files — feel free to reorganize them in a similar way if needed.

Next, we’ll create two files.

AuthService.php – Handling Authentication Logic

<?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()
    {
    }

    /**
     * @throws \Throwable
     */
    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
    {
        /** @var \App\Models\User $user */
        $user = User::query()
            ->where('email', $email)
            ->first();

        if (!$user) {
            return null;
        }

        return $user;
    }
}

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';
    }
}

Now let’s wire up our controller. To do this, create a new file inside the routes directory and name it admin.php, resulting in the path:

routes/admin.php.

And we’ll also register this file in bootstrap/app.php.

<?php

use Illuminate\Support\Facades\Route;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Routing\Middleware\SubstituteBindings;


return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        using: function () {
            Route::middleware('admin')
                ->prefix('admin')
                ->group(base_path('routes/admin.php'));
            Route::middleware('api')
                ->prefix('api')
                ->group(base_path('routes/api.php'));
        },
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php'
    )
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->group('admin', [SubstituteBindings::class]);
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        //
    })->create();

Next, we’ll add a seed for the initial user. Go to the database/seeders directory and update the DatabaseSeeder.php file.

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     */
    public function run(): void
    {
        // User::factory(10)->create();

        User::factory()->create([
            'name' => 'Test User',
            'email' => '[email protected]',
            'password' => '12345678',
        ]);
    }
}

After that, enter the command php artisan db:seed in the console and restart our backend with composer run dev. Then, try logging in using the data from the seeds.

The block at the bottom is red with an error or something fancy.

If you encounter a CORS error in the request, do the following:

  1. Run the command php artisan config:publish cors to generate a config file for CORS settings.
  2. In the newly created file (config/cors.php), add /admin to the paths line. It should look something like this:
'paths' => ['api/*', 'sanctum/csrf-cookie', 'admin/*'],

Once everything is configured properly and the login succeeds, you should be redirected into the admin panel.

<b>LimitLoginAttempts.php</b>

You’ve successfully set up Admiral and connected authentication. You’re now ready to start building out your CRUD operations – but that’s a topic for another article.

MaxB - dev.family

Got questions about the initialization process? Feel free to reach out – we’re happy to help

Max B. CEO

Schedule a meeting

You may also like: