In this guide, we’ll show how to develop an admin panel for a small project using Laravel’s built-in tools. This process helps you better understand the framework’s structure, improve your Laravel skills, and gain control over every part of the application.
To follow this guide, you will need:
We will use the following environment:
Create a user named lara
and add it to the sudo and www-data
groups:
adduser lara
usermod -aG sudo lara
usermod -aG www-data lara
su - lara
Add the PHP PPA:
sudo add-apt-repository ppa:ondrej/php -y
sudo apt update
Install PHP and extensions:
sudo apt install php8.3 php8.3-cli php8.3-fpm php8.3-mysql php8.3-curl php8.3-gd php8.3-mbstring php8.3-bcmath php8.3-xml php8.3-zip php8.3-intl php8.3-soap php8.3-redis -y
Update package list:
sudo apt update
Download the installer script:
cd /usr/local/bin
sudo curl -sS https://getcomposer.org/installer -o composer-setup.php
Verify the file integrity:
HASH=$(curl -sS https://composer.github.io/installer.sig)
php -r "if (hash_file('sha384', 'composer-setup.php') === '$HASH') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
Install Composer:
sudo php composer-setup.php --install-dir=/usr/local/bin --filename=composer
sudo composer self-update 2.7.8
Check the installation:
composer --version
Remove old versions:
sudo apt purge nodejs -y
sudo apt autoremove -y
sudo apt update
Install Node.js 18.x:
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt install -y nodejs
Check versions:
node -v
npm -v
Create a folder for the project:
cd /var
sudo mkdir www
sudo chown lara:www-data www
sudo chmod 775 www
cd www
Install Nginx:
sudo apt install -y nginx
sudo ufw allow 'Nginx Full'
Configure a virtual host:
sudo nano /etc/nginx/sites-available/lara.com
Add these directives:
server {
listen 80;
server_name <server_IP_address_or_domain_name>;
root /var/www/laravel-admin/public;
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
}
location ~ /\.ht {
deny all;
}
}
Enable the configuration:
sudo ln -s /etc/nginx/sites-available/lara.com /etc/nginx/sites-enabled/
sudo unlink /etc/nginx/sites-enabled/default
Restart the services:
sudo systemctl restart nginx
sudo systemctl restart php8.3-fpm
Create a project:
composer create-project laravel/laravel:^9.0 laravel-admin
Configure permissions:
sudo find /var/www/laravel-admin -type f -exec chmod 644 {} \;
sudo find /var/www/laravel-admin -type d -exec chmod 755 {} \;
cd laravel-admin/
sudo chown -R lara:www-data .
sudo find . -type f -exec chmod 664 {} \;
sudo find . -type d -exec chmod 775 {} \;
sudo chgrp -R www-data storage bootstrap/cache
sudo chmod -R ug+rwx storage bootstrap/cache
Install the SQLite extension:
sudo apt install php8.3-sqlite3
Set up the connection. In the .env
file, modify these lines:
DB_CONNECTION=sqlite
DB_DATABASE=/var/www/laravel-admin/database/database.sqlite
Create a database:
touch database/database.sqlite
Change the owner and group for the database.sqlite
file:
sudo chown lara:www-data /var/www/laravel-admin/database/database.sqlite
Run migrations:
php artisan migrate
The php artisan
command must be executed from the project root: /var/www/laravel-admin
.
Providers are an important part of Laravel. They are special classes used to configure and initialize application components. They allow you to define how different elements of the system should be organized and linked.
Laravel automatically loads the providers listed in the config/app.php
file, which ensures flexibility and modularity in managing the application.
RouteServiceProvider is a system provider responsible for routing in the application. Its tasks include:
This provider helps organize routes by splitting them into groups (for example, for the admin panel and the user-facing part) and defining common rules for the routes, such as middleware, prefixes, or constraints.
The RouteServiceProvider.php
file is located in the app/Providers
folder. We will change the value of the HOME
constant and edit the boot method:
class RouteServiceProvider extends ServiceProvider
{
public const HOME = '/dashboard';
public function boot()
{
$this->configureRateLimiting();
$this->routes(function () {
Route::middleware('api')
->prefix('api')
->group(base_path('routes/api.php'));
Route::middleware('web')
->group(base_path('routes/web.php'));
Route::middleware(['web', 'auth'])
->namespace('App\Http\Controllers\Dashboard')
->prefix('dashboard')
->group(base_path('routes/dashboard.php'));
});
}
// Remaining provider code
}
HOME
property:Defines the redirect route for authenticated users after successful login. In this case, users will be redirected to /dashboard
. This is convenient for centralized redirect management: if the route changes, it’s enough to update only the constant value.
public const HOME = '/dashboard';
boot
method:The boot
method is where the main routing configuration takes place. Here, the routes
method is used, which allows defining routing rules for different parts of the application.
Route::middleware('web')
->group(base_path('routes/web.php'));
Client-side routes are included from the routes/web.php
file. They only use the web middleware, which provides support for sessions, CSRF protection, and other web features.
Route::middleware(['web', 'auth'])
->namespace('App\Http\Controllers\Dashboard')
->prefix('dashboard')
->group(base_path('routes/dashboard.php'));
For the admin panel routes, the following are added:
web
and auth
middleware, restricting access to authenticated users only.App\Http\Controllers\Dashboard
.dashboard
, making them accessible via URLs like /dashboard/...
.routes/dashboard.php
.This structure makes the application modular and scalable:
Create a file routes/dashboard.php
and add the following route:
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('dashboard.app');
})->name('dashboard.home');
Route description
Route::get('/'): This route responds to GET requests at the /dashboard
address (since the RouteServiceProvider
sets the prefix dashboard
for the admin panel).
Callback function: The callback returns the view dashboard.app
. This is the base template of the admin panel, created earlier, and inherits its structure from master.blade.php
.
Route naming: ->name('dashboard.home')
. Naming allows you to reference the route by dashboard.home
in templates, controllers, or when generating links. Example in a Blade template:
<a href="{{ route('dashboard.home') }}">Home</a>
Purpose of the route:
To use standard frontend styles and authentication in Laravel, you need to install the laravel/ui
package. This package provides basic components such as authentication, login and registration pages, and also allows the use of frontend frameworks such as Bootstrap or Vue.js.
Install the package via Composer:
composer require laravel/ui
After installing the package, run the command to generate a frontend skeleton using Bootstrap:
php artisan ui bootstrap
This command must be run from the project root: /var/www/laravel-admin
.
It will create the necessary files to integrate Bootstrap into the project, including templates and styles.
Once you have installed and set up laravel/ui
, you can generate standard authentication pages (login and registration). Run the command:
php artisan ui bootstrap --auth
Agree to recreate the Controller.php
file.
This command will create the necessary controllers, routes, and views for authentication:
Install the required JavaScript dependencies and compile resources using npm:
npm install && npm run build
If the installation fails, run the command again.
After installing the laravel/ui
package and configuring authentication, Laravel automatically adds some files and routes that are not needed when developing an admin panel. To clean them up, do the following:
The default HomeController.php
is often unused since its functionality is replaced by your own controllers.
To delete it:
rm app/Http/Controllers/HomeController.php
Laravel automatically creates a home route:
Route::get('/home', [App\Http\Controllers\HomeController::class, 'index'])->name('home');
You can remove it from routes/web.php
, since the authentication redirect route is now defined by the HOME
constant in RouteServiceProvider
.
In the controllers responsible for authentication and authorization, replace the redirect path after successful login/registration with the constant RouteServiceProvider::HOME
.
Find and open the controllers located in app/Http/Controllers/Auth/
:
ConfirmPasswordController.php
LoginController.php
RegisterController.php
ResetPasswordController.php
VerificationController.php
Import the namespace in each controller:
use App\Providers\RouteServiceProvider;
Then update the $redirectTo
property.
By default, Laravel sets it to /home
:
protected $redirectTo = '/home';
Change it to:
protected $redirectTo = RouteServiceProvider::HOME;
Example for LoginController.php
:
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
class LoginController extends Controller
{
use AuthenticatesUsers;
protected $redirectTo = RouteServiceProvider::HOME;
// Rest of the controller code
}
Do the same in all other controllers where $redirectTo
is defined.
After completing the steps above, test the login and registration pages:
Clear the application cache:
php artisan config:clear
Run the php artisan
command from the project root: /var/www/laravel-admin
.
Open a browser and go to:
http://<Server_Public_IP>/login
to access the login page.http://<Server_Public_IP>/register
to register a new user.Now, basic authentication in your Laravel project is ready. You can use it as a foundation for further customization and expansion.
Create a folder for the admin panel templates:
mkdir -p resources/views/dashboard/layouts
Create the base template resources/views/dashboard/layouts/master.blade.php
:
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
@stack('style')
</head>
<body class="container-fluid" style="height: 100%; margin: 0;">
<div class="wrapper row" style="height: 100%;">
<nav id="sidebar" class="p-3 bg-dark d-flex flex-column col-2" style="height: 100vh;">
@include('dashboard.layouts.sidebar')
</nav>
<div id="content" class="col" style="height: 100vh; overflow-y: auto;">
@include('dashboard.layouts.nav')
<div class="container-fluid mt-4 mb-4">
@if(Session::has('global'))
<div class="alert alert-success">
{{ Session::get('global') }}
</div>
@endif
@yield('content')
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
@stack('scripts')
</body>
</html>
Key elements of the template:
{{ csrf_token() }}
: Generates a unique token to protect against CSRF attacks.@stack('style') and @stack('scripts')
: Allow adding styles and scripts from child templates via @push
.@include('dashboard.layouts.nav')
: Includes the Blade template with the top navigation.<script src="https://cdn.jsdelivr.net/..."></script>
: Connects Bootstrap via CDN for quick use of ready-made frontend styles and components.{{ Session::get('global') }}
: Displays notifications stored in the global session variable.Sidebar menu: resources/views/dashboard/layouts/sidebar.blade.php
:
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link text-white" href="{{ route('dashboard.home') }}">Home</a>
</li>
</ul>
Top navigation: resources/views/dashboard/layouts/nav.blade.php
:
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<span class="navbar-brand">Dashboard</span>
</div>
</nav>
Create resources/views/dashboard/app.blade.php
:
@extends('dashboard.layouts.master')
@section('content')
<p>How to Create an Admin Panel in Laravel: Step-by-Step Guide</p>
@endsection
This file extends the base template master.blade.php
and defines unique content for each page.
After completing all the previous steps, do the following to test authentication:
Clear caches and temporary data from the project root:
php artisan optimize:clear
Register your first user via the route:
http://<Server_Public_IP>/register
Then, you will be able to log in.
Create the Post model file along with its migration file (using the -m
flag) with the command:
php artisan make:model Post -m
Laravel will automatically create two files:
app/Models/Post.php
:namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
use HasFactory;
}
database/migrations/YYYY_MM_DD_create_posts_table.php
:use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('posts');
}
};
Add title and content columns inside the migration file database/migrations/YYYY_MM_DD_create_posts_table.php
:
$table->string('title');
$table->text('content');
Final version of the migration file:
return new class extends Migration
{
public function up()
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('content');
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('posts');
}
};
Run the migration to create the table in the database:
php artisan migrate
In the app/Models/Post.php
file, add the following property:
protected $fillable = ['title', 'content'];
Final version of the Post
model:
class Post extends Model
{
use HasFactory;
protected $fillable = ['title', 'content'];
}
$fillable
lists the fields that can be mass-assigned, e.g., via Post::create()
.HasFactory
allows using factories for testing.Generate a controller for the Post
model with:
php artisan make:controller Dashboard/PostController -r --model=Post
Laravel will automatically create the file app/Http/Controllers/Dashboard/PostController.php
:
namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function index()
{
//
}
public function create()
{
//
}
public function store(Request $request)
{
//
}
public function show(Post $post)
{
//
}
public function edit(Post $post)
{
//
}
public function update(Request $request, Post $post)
{
//
}
public function destroy(Post $post)
{
//
}
}
A resource controller in Laravel is a special type of controller that automatically generates empty methods for standard CRUD operations (Create, Read, Update, Delete) on a model.
It simplifies the process of creating routes and controllers for managing resources.
The methods follow a RESTful approach and include:
index
: display a list of recordscreate
: show the form to create a new recordstore
: save a new record in the databaseedit
: show the form to edit a recordupdate
: update a record in the databasedestroy
: delete a recordRemove the show
method and update the remaining methods in app/Http/Controllers/Dashboard/PostController.php
:
class PostController extends Controller
{
public function index()
{
$posts = Post::latest()->paginate(10);
return view('dashboard.posts.index', compact('posts'));
}
public function create()
{
return view('dashboard.posts.create');
}
public function store(Request $request)
{
$request->validate([
'title' => 'required|string|max:255',
'content' => 'required',
]);
Post::create($request->only('title', 'content'));
return redirect()->route('posts.index')->with('global', 'Post created successfully.');
}
public function edit(Post $post)
{
return view('dashboard.posts.edit', compact('post'));
}
public function update(Request $request, Post $post)
{
$request->validate([
'title' => 'required|string|max:255',
'content' => 'required',
]);
$post->update($request->only('title', 'content'));
return redirect()->route('posts.index')->with('global', 'Post updated successfully.');
}
public function destroy(Post $post)
{
$post->delete();
return redirect()->route('posts.index')->with('global', 'Post deleted successfully.');
}
}
In routes/dashboard.php
, add the resource controller route:
use App\Http\Controllers\Dashboard\PostController;
Route::resource('posts', PostController::class);
This route will automatically create access to all resource controller methods (index
, create
, store
, etc.).
In resources/views/dashboard/layouts/sidebar.blade.php
, add a link to manage posts:
<li class="nav-item">
<a class="nav-link text-white" href="{{ route('posts.index') }}">Posts</a>
</li>
Final version of sidebar.blade.php
:
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link text-white" href="{{ route('dashboard.home') }}">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-white" href="{{ route('posts.index') }}">Posts</a>
</li>
</ul>
When you define a resource route:
Route::resource('posts', PostController::class);
Laravel automatically generates named routes for the controller methods:
posts.index
: display the list of posts (index
method).posts.create
: show the form to create a post (create
method).posts.store
: save a new post (store
method).posts.edit
: show the form to edit a post (edit
method).posts.update
: update a post (update
method).posts.destroy
: delete a post (destroy
method).Now, the admin panel menu will include a “Posts” item that leads to the posts management page.
The Request
class in Laravel is responsible for handling incoming HTTP requests. It provides:
What does the Request
class do?
Request
class (e.g., requiring that the title
field is mandatory and of a certain length).authorize()
method returns false
, the request will be rejected with status 403.validated()
method returns only the data that passed validation. This prevents unwanted input from being stored or used in your application logic.Request
class, reducing controller complexity.To create a custom Request
class for the Post
model, run:
php artisan make:request PostRequest
Laravel will create the file app/Http/Requests/PostRequest.php
:
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PostRequest extends FormRequest
{
public function authorize()
{
return false;
}
public function rules()
{
return [
//
];
}
}
Update the authorize
and rules
methods:
class PostRequest extends FormRequest
{
/**
* Determines if the user is authorized to make this request.
*/
public function authorize()
{
return true; // Replace with actual check if authorization is required.
}
/**
* Defines validation rules for the request data.
*/
public function rules()
{
return [
'title' => 'required|string|max:255',
'content' => 'required',
];
}
/**
* Custom validation error messages.
*/
public function messages()
{
return [
'title.required' => 'The "Title" field is required.',
'content.required' => 'The "Content" field is required.',
];
}
}
Sometimes it’s useful to create two different request classes for create and update operations:
PostStoreRequest
for validating data when creating a post.PostUpdateRequest
for validating data when updating a post.This allows you to apply different validation rules. For example:
title
field might be required.title
could be optional if it isn’t being changed.In app/Http/Controllers/Dashboard/PostController.php
:
Import the namespace:
use App\Http\Requests\PostRequest;
Update the store
and update
methods:
namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Http\Requests\PostRequest;
class PostController extends Controller
{
// Other methods...
public function store(PostRequest $request)
{
Post::create($request->validated());
return redirect()->route('posts.index')->with('global', 'Post created successfully.');
}
public function update(PostRequest $request, Post $post)
{
$post->update($request->validated());
return redirect()->route('posts.index')->with('global', 'Post updated successfully.');
}
// Other methods...
}
The $request->validated()
method returns only the fields that passed validation.
title
is empty when it’s required).validated()
returns a clean array of safe data that you can use for creating or updating a record.This ensures your application never processes invalid or unsafe data.
1. Create a Directory for Post
Views:
mkdir -p resources/views/dashboard/posts
2. Index View resources/views/dashboard/posts/index.blade.php
:
@extends('dashboard.layouts.master')
@section('content')
<div class="d-flex justify-content-between align-items-center">
<h1>Posts</h1>
<a href="{{ route('posts.create') }}" class="btn btn-primary">Create Post</a>
</div>
<table class="table mt-4">
<thead>
<tr>
<th>#</th>
<th>Title</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach($posts as $post)
<tr>
<td>{{ $post->id }}</td>
<td>{{ $post->title }}</td>
<td>
<a href="{{ route('posts.edit', $post) }}" class="btn btn-warning btn-sm">Edit</a>
<form action="{{ route('posts.destroy', $post) }}" method="POST" class="d-inline-block">
@csrf
@method('DELETE')
<button class="btn btn-danger btn-sm" onclick="return confirm('Are you sure?')">Delete</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
{{ $posts->links() }}
@endsection
3. Create View resources/views/dashboard/posts/create.blade.php
:
@extends('dashboard.layouts.master')
@section('content')
<h1>Create Post</h1>
<form action="{{ route('posts.store') }}" method="POST">
@csrf
<div class="mb-3">
<label for="title" class="form-label">Title</label>
<input type="text" class="form-control" id="title" name="title" value="{{ old('title') }}">
@error('title')
<p class="text-danger">{{ $message }}</p>
@enderror
</div>
<div class="mb-3">
<label for="content" class="form-label">Content</label>
<textarea class="form-control" id="content" name="content" rows="5">{{ old('content') }}</textarea>
@error('content')
<p class="text-danger">{{ $message }}</p>
@enderror
</div>
<button type="submit" class="btn btn-success">Save</button>
</form>
@endsection
4. Edit View resources/views/dashboard/posts/edit.blade.php
:
@extends('dashboard.layouts.master')
@section('content')
<h1>Edit Post</h1>
<form action="{{ route('posts.update', $post) }}" method="POST">
@csrf
@method('PUT')
<div class="mb-3">
<label for="title" class="form-label">Title</label>
<input type="text" class="form-control" id="title" name="title" value="{{ old('title', $post->title) }}">
@error('title')
<p class="text-danger">{{ $message }}</p>
@enderror
</div>
<div class="mb-3">
<label for="content" class="form-label">Content</label>
<textarea class="form-control" id="content" name="content" rows="5">{{ old('content', $post->content) }}</textarea>
@error('content')
<p class="text-danger">{{ $message }}</p>
@enderror
</div>
<button type="submit" class="btn btn-success">Update</button>
</form>
@endsection
Blade elements:
@extends
: Used to inherit a base template (e.g., master.blade.php
), which defines the common layout (header, menu, styles, scripts).@section
: Defines the content that will be injected into the @yield
sections of the base template.{{ old('title') }}
: Retrieves the previously entered form value if validation fails and the page reloads.@error
: Displays validation error messages for a specific field.After creating these templates:
Create a controller for handling client-side requests:
php artisan make:controller PostController
In app/Http/Controllers/PostController.php
:
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function show()
{
$posts = Post::latest()->paginate(10);
return view('posts.index', compact('posts'));
}
}
In routes/web.php
:
use App\Http\Controllers\PostController;
Route::get('/', [PostController::class, 'show'])->name('posts.index');
This replaces the default welcome route:
// Old
Route::get('/', function () {
return view('welcome');
});
Create the posts
directory:
mkdir -p resources/views/posts
Create resources/views/posts/index.blade.php
:
@extends('layouts.app')
@section('content')
<div class="container mt-4">
<h1>Posts</h1>
@foreach($posts as $post)
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">{{ $post->title }}</h5>
<p class="card-text">{{ $post->content }}</p>
</div>
</div>
@endforeach
<div class="mt-4">
{{ $posts->links() }}
</div>
</div>
@endsection
Now, the homepage (http://localhost:8000/
) will display the list of posts with titles and content.
You have built a basic admin panel and client-side frontend in Laravel!
Your project now includes:
Flexibility & scalability: Custom Request classes and models.
Future-ready: You can easily extend the app with categories, tags, search, and filters.