Timesheet System - Initial Setup & Authentication
Timesheet System - Initial Setup & Authentication
We concluded the last blog post with an introduction to what might turn out to be a project that is far too ambitious for me, or perhaps not—time will tell. This time, we’re commencing the build of that project. Initially, it shouldn’t be too challenging; our primary aim is Initial Setup and Authentication.
I fully intended for this project to be as much about learning Laravel 11 for me as it was about producing several blog articles. However, I did not progress very far into Laravel 11 before getting lost in a few changes. I started again, encountered different changes, and got lost all over again.
At this stage, I find myself more excited about the project rather than learning Laravel 11. Therefore, I am going to shift focus and build using Laravel 10. So, let’s dive in and get a project up and running:
composer create-project laravel/laravel staff-tracker "10.*"
I called the project Staff-Tracker because I started by forgetting the main purpose, this is a timesheet system and at this stage it doesn’t really need to do anything else. Never mind, a name is just a name, moving on…
It is probably unnecessary, but I always push my raw basic project structure straight to GitHub. This way, I can freely experiment, knowing that if I make a mistake, I can quickly locate the broken file (in the default config) and rectify my mess. This might not be essential, but I do it out of practice, ensuring my GitHub repository starts with a copy of a basic Laravel project before any modifications.
As a reminder, I use the Laravel Herd application, which allows me to open my browser and type staff-tracker.test
to see the new site immediately.
Laravel Breeze
One of the things I love about working with Laravel is the abundance of ready-made components. I am going to use Breeze, which provides numerous Authentication components right away. Let’s add it:
composer require laravel/breeze --dev
php artisan breeze:install
npm install
Adding a Database
We do not yet have a database, which is essential. I will stick with SQLite for this project. Locate and modify the ./.env
file in the root of your new project:
# make changes here:
DB_CONNECTION=sqlite
DB_DATABASE=database.sqlite
Next, we can perform our first database migration, which will create some initial tables for us:
php artisan migrate
I executed this a couple of times and may have forgotten the correct method. In my tests, this command created the database.sqlite file in my project’s root. However, when I tried to register a user account, the registration failed. I moved the database to the ./database directory, and it started working correctly. Assuming I had made a mistake, I repeated the process. On the second attempt, the sqlite file was again created at the root and needed to be moved for user registration to work. I’ll attribute this to a personal issue for now and may need to refresh my knowledge to resolve it without manual intervention. I note this in case anyone else encounters the same problem.
That’s about it for now—we have a basic project setup and authentication working. Sprint 1 finished? I think not; there’s still more to do.
Sprint 1 - Initial Setup & Authentication
As a reminder, I will be using AirTable to manage this project. Our Sprint is outlined as follows:
Authentication
At the moment, we can navigate to the project URL using Herd or php artisan serve
, and we will see the default Laravel welcome page. By adding Breeze, it’s impressive to see that we have some basic authentication components available immediately. The basic Laravel page now features Login and Register options in the top right:
User Table
Later on, I will need to check if a user is active and whether they are a team member or a manager, etc. I’ll achieve this by creating a new migration in the ./database/migrations/
directory. We should use Artisan to assist rather than creating this file manually:
php artisan make:migration add_role_to_users_table --table=users
Think of migration files as a form of version control for your database. The created file will have a timestamp to indicate when it was created, aiding in understanding when and how changes were introduced to your database. We can open that new file and begin describing the table:
Table Description
<?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::table('users', function (Blueprint $table) {
$table->string('role')->default('team_member');
});
}
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('role');
});
}
};
Middleware - Role Checking
As we develop the application, the users’ roles and their relationships to other users will become increasingly important. We will start with some simple role-checking functions.
php artisan make:middleware CheckRole
The command creates a file that contains the boilerplate structure. We need to make some changes:
CheckRole.php
// app/Http/Middleware/CheckRole.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class CheckRole
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|array $roles
* @return mixed
*/
public function handle(Request $request, Closure $next, ...$roles)
{
if (!$request->user() || !$request->user()->hasRole($roles)) {
abort(403, 'Unauthorized action.');
}
return $next($request);
}
}
You may have noticed the comments at the top of the file:
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string $role
* @return mixed
*/
These may appear as simple comments, but they are actually PHP DocBlock comments. They are used for purposes such as aiding your IDE with auto-complete and can be parsed by documentation generators. Essentially, they describe the method parameters and return types. They are not strictly essential, but they are quite helpful.
We should register our new Middleware in the ./app/Http/Kernel.php
file:
protected $middlewareAliases = [
// ...
'role' => \App\Http\Middleware\CheckRole::class,
];
Password Change (New Users)
For this version of the project, we will assume that a new user will not register themselves in the system (more on this later). Instead, they should be created by an administrator. To keep things simple, we will assume that the admin creates user accounts with simple passwords, sends the user the login details, and the user is responsible for setting their own password upon first login. Let’s begin with that.
php artisan make:migration add_password_changed_at_to_users_table --table=users
We can expect another migration file. Make the following changes:
Check Password field in the Users Table
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('password_changed_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('password_changed_at');
});
}
};
php artisan migrate
Shifting focus to some middleware to manage these checks.
php artisan make:middleware CheckPasswordChanged
CheckPasswordChanged.php (Middleware)
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class CheckPasswordChanged
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if (Auth::check()) {
// prevent redirect loops !!
if (!$request->routeIs('password.change') && !$request->routeIs('password.change.post')) {
if (is_null(Auth::user()->password_changed_at)) {
return redirect()->route('password.change');
}
}
}
return $next($request);
}
}
And, of course, register the new middleware:
'password.changed' => \App\Http\Middleware\CheckPasswordChanged::class,
Controllers
We need to shift focus to some Controllers. We can do this one at a time, create the controller, update the file, or create all the controllers at once, which is what I will do:
php artisan make:controller PasswordChangeController
php artisan make:controller AdminController
php artisan make:controller ManagerController
php artisan make:controller TimesheetController
php artisan make:controller Admin/UserController --resource
Let’s examine the last command as it is quite useful:
php artisan
= Similar to saying Hey Siri or Hey Google—it signals the system to expect an instruction.make:controller
= Creates a controller file for us, akin to saying ‘get this cookie cutter shape’.Admin/UserController
= Provides both a path and a filename.--resource
= This is the advantageous part; it tells Laravel that the cookie cutter we want to use for our controller should include all the common elements of a CRUD application (Create, Read, Update, Destroy).
The controller files are located in ./app/Http/Controllers/
. I won’t delve into the code for each one in this post as it is already extensive, but I will provide access to the GitHub Repository for public viewing.
Views
As with the controllers, I will not cover the code for the views here. The GitHub repository will contain some basic views for you to work with.
Routes
The Routes file will require substantial work. I’ll add comments to help understand the structure and hopefully make it easier for me (or anyone else) to revisit and make changes in the future:
./routes/web.php
<?php
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\PasswordChangeController;
use App\Http\Controllers\AdminController;
use App\Http\Controllers\Admin\UserController;
use App\Http\Controllers\ManagerController;
use App\Http\Controllers\TimesheetController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Public Routes
|--------------------------------------------------------------------------
*/
Route::get('/', function () {
return view('welcome');
});
/*
|--------------------------------------------------------------------------
| Authentication Routes
|--------------------------------------------------------------------------
*/
require __DIR__.'/auth.php';
/*
|--------------------------------------------------------------------------
| Password Change Routes
|--------------------------------------------------------------------------
*/
// Accessible to authenticated and verified users
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/password/change', [PasswordChangeController::class, 'showChangeForm'])->name('password.change');
Route::post('/password/change', [PasswordChangeController::class, 'changePassword'])->name('password.change.post');
});
/*
|--------------------------------------------------------------------------
| Protected Routes
|--------------------------------------------------------------------------
*/
Route::middleware(['auth', 'verified', 'password.changed'])->group(function () {
// Dashboard accessible by all authenticated users
Route::get('/dashboard', function () {
return view('dashboard');
})->name('dashboard');
/*
|--------------------------------------------------------------------------
| Profile Routes
|--------------------------------------------------------------------------
*/
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
/*
|--------------------------------------------------------------------------
| Admin Routes
|--------------------------------------------------------------------------
*/
Route::middleware('role:admin')->prefix('admin')->name('admin.')->group(function () {
// Admin Dashboard
Route::get('/dashboard', [AdminController::class, 'index'])->name('dashboard');
// User Management
Route::resource('users', UserController::class);
// ... other admin routes
});
/*
|--------------------------------------------------------------------------
| Manager Routes
|--------------------------------------------------------------------------
*/
Route::middleware('role:manager')->prefix('manager')->name('manager.')->group(function () {
// Manager Dashboard
Route::get('/dashboard', [ManagerController::class, 'index'])->name('dashboard');
// ... other manager routes
});
/*
|--------------------------------------------------------------------------
| Team Member Routes
|--------------------------------------------------------------------------
*/
Route::middleware('role:team_member')->prefix('timesheets')->name('timesheets.')->group(function () {
// Timesheets Index
Route::get('/', [TimesheetController::class, 'index'])->name('index');
// ... other timesheet routes
});
});
Removing Registration
This application will be an internal tool used for tracking timesheets. We do not want new users to sign up on their own, as that is not how timesheet systems typically operate. There will be an administrator, and only the admin (or admins) will be able to create new users.
This allows us to remove the registration functionality and focus on user management.
We can achieve this quickly by commenting out some lines in ./routes/auth.php
:
// use App\Http\Controllers\Auth\RegisteredUserController; // Commented out
// ...
// Comment out the registration routes
// Route::get('/register', [RegisteredUserController::class, 'create'])
// ->middleware('guest')
// ->name('register');
// Route::post('/register', [RegisteredUserController::class, 'store'])
// ->middleware('guest');
This is a straightforward fix. We can also remove any links from the Blade views, but from my testing, this is not currently necessary.
Creating the first Admin User
Previously, we could register an account and then manually mark it as an admin in the database. However, we must assume that this manual process is not feasible for most system users. Therefore, we need to build a mechanism to create the initial admin. There are several options for this, but let’s proceed with the following approach: create a Seeder for the Initial Admin.
php artisan make:seeder AdminUserSeeder
This will produce a new seeder file ./database/seeders/AdminUserSeeder.php
. We need to make some changes as follows:
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use App\Models\User;
class AdminUserSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
User::create([
'name' => 'Admin User',
'email' => 'admin@example.com',
'password' => Hash::make('password'), // Change to a secure password
'role' => 'admin',
'password_changed_at' => now(),
'email_verified_at' => now(),
]);
}
}
You need to modify those values to match your initial admin user account.
Run the new seeder:
php artisan db:seed --class=AdminUserSeeder
I initially encountered a couple of errors. The first was straightforward: I had 'email_verified_at' => now(),
but did not have this column in my users table. The other issue occurred when I attempted to upgrade one of my existing test users to an admin. This resulted in a new database entry, but one of my checks ensures unique email addresses, requiring manual intervention.
Role-Based Access Control (RBAC)
We need to define what different user groups can do by establishing roles and outlining their permissions.
A possible starting point could be:
- A normal user can create timesheets.
- A normal user can submit timesheets for approval.
- A Team Manager can approve or reject timesheets.
- A Team Manager can submit their own timesheet.
- A Team Manager should not be able to approve their own timesheet.
- An Administrator can create user accounts.
- An Administrator can approve or reject timesheets.
- An Administrator can change a user’s role (e.g., from User to Team Manager).
- An Administrator should be able to create additional Administrator accounts.
- An Administrator should not be able to submit a timesheet (they should have a regular account for that).
Role Constants
To minimise errors, we should avoid having the Admin type the role of each user manually. One way to define roles and ensure consistency is through the use of Enums.
namespace App\Enums;
enum Role: string
{
case Admin = 'admin';
case Manager = 'manager';
case TeamMember = 'team_member';
}
However, I tested this approach and encountered an issue with my Blade files expecting a string rather than an Enum. There might be a solution, but honestly, it is not something I resolved. I was more interested in the backend than the frontend, so I will instead use strings to define the user types.
We need to revisit the CheckRole.php middleware:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class CheckRole
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string $role
* @return mixed
*/
public function handle(Request $request, Closure $next, string $role)
{
if (!Auth::check() || Auth::user()->role !== $role) {
abort(403, 'Unauthorized action.');
}
return $next($request);
}
}
Administrators should be able to create users, assign roles, and manage existing users.
The first step is to revisit the UserController:
./app/Http/Controllers/Admin/UserController.php
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class UserController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
// List the users
$users = User::where('role', '!=', Role::Admin->value)->paginate(10);
return view('admin.users.index', compact('users'));
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
// Create new users
$roles = Role::cases();
return view('admin.users.create', compact('roles'));
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|confirmed|min:8',
'role' => 'required|in:admin,manager,team_member',
]);
User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'role' => $request->role,
'password_changed_at' => now(),
]);
return redirect()->route('admin.users.index')->with('success', 'User created successfully.');
}
/**
* Display the specified user.
*/
public function show(User $user)
{
// Show user details
return view('admin.users.show', compact('user'));
}
/**
* Show the form for editing the specified resource.
*/
public function edit(User $user)
{
// Edit a user
$roles = Role::cases();
return view('admin.users.edit', compact('user', 'roles'));
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, User $user)
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users,email,' . $user->id,
'password' => 'nullable|string|confirmed|min:8',
'role' => 'required|in:admin,manager,team_member',
]);
$user->name = $request->name;
$user->email = $request->email;
$user->role = $request->role;
if ($request->filled('password')) {
$user->password = Hash::make($request->password);
$user->password_changed_at = now();
}
$user->save();
return redirect()->route('admin.users.index')->with('success', 'User updated successfully.');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(User $user)
{
$user->delete();
return redirect()->route('admin.users.index')->with('success', 'User deleted successfully.');
}
}
Again, any Blade view files are beyond the scope of this post, but you can find them in the GitHub Repository.
We are roughly where we want to be at the end of the sprint:
- Initial setup of a new Laravel project.
- Authentication (Laravel Breeze).
- Role-Based Access Control (initial steps).
- Ability to Create, Read, Update, and Delete (CRUD) system users.
Styling (Basic Layout)
Styling isn’t going to be a major focus of this project, which is why I didn’t concentrate heavily on the Blade files. You’re free to define a styling that suits you, but I aim for something clean and office-friendly.
Styling isn’t my focus, but that is kind of dull and the thing I like about building things like this is that instant feedback loop, so I have since included an element of basic layout using Tailwind for styling:
module.exports = {
darkMode: 'class', // Enable dark mode
content: [
'./resources/**/*.blade.php',
'./resources/**/*.js',
'./resources/**/*.vue',
],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
colors: {
primary: {
light: '#3b82f6', // Blue-500
DEFAULT: '#1d4ed8', // Blue-700
dark: '#1e40af', // Blue-800
},
secondary: {
light: '#6b7280', // Gray-500
DEFAULT: '#4b5563', // Gray-600
dark: '#374151', // Gray-700
},
},
spacing: {
'128': '32rem',
'144': '36rem',
},
},
},
plugins: [],
}
Additionally, I plan to set up some custom elements like cards and buttons:
@props(['type' => 'primary', 'href' => '#'])
@php
$classes = 'font-semibold py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-opacity-75 inline-block ';
$classes .= match($type) {
'primary' => 'bg-blue-500 hover:bg-blue-600 text-white',
'secondary' => 'bg-gray-500 hover:bg-gray-600 text-white',
'danger' => 'bg-red-500 hover:bg-red-600 text-white',
default => 'bg-blue-500 hover:bg-blue-600 text-white',
};
@endphp
<a href="{{ $href }}" {{ $attributes->merge(['class' => $classes]) }}>
{{ $slot }}
</a>
I’ll incorporate these into my index as follows:
./resources/views/admin/users/index.blade.php
<x-app-layout>
<div class="container mx-auto px-4">
<x-card>
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold">User Management</h2>
<x-link-button type="primary" href="{{ route('admin.users.create') }}">Create New User</x-link-button>
</div>
@if(session('success'))
<x-alert type="success">
{{ session('success') }}
</x-alert>
@endif
<div class="overflow-x-auto">
<table class="min-w-full bg-white dark:bg-gray-800">
<thead>
<tr>
<th class="py-2 px-4 border-b border-gray-200 dark:border-gray-700 text-left">Name</th>
<th class="py-2 px-4 border-b border-gray-200 dark:border-gray-700 text-left">Email</th>
<th class="py-2 px-4 border-b border-gray-200 dark:border-gray-700 text-left">Role</th>
<th class="py-2 px-4 border-b border-gray-200 dark:border-gray-700 text-left">Actions</th>
</tr>
</thead>
<tbody>
@forelse($users as $user)
<tr class="hover:bg-gray-100 dark:hover:bg-gray-700">
<td class="py-2 px-4 border-b border-gray-200 dark:border-gray-700">{{ $user->name }}</td>
<td class="py-2 px-4 border-b border-gray-200 dark:border-gray-700">{{ $user->email }}</td>
<td class="py-2 px-4 border-b border-gray-200 dark:border-gray-700">
{{ ucfirst(str_replace('_', ' ', $user->role)) }}
</td>
<td class="py-2 px-4 border-b border-gray-200 dark:border-gray-700">
<x-link-button type="secondary" href="{{ route('admin.users.show', $user->id) }}" class="mr-2">View</x-link-button>
<x-link-button type="primary" href="{{ route('admin.users.edit', $user->id) }}" class="mr-2">Edit</x-link-button>
<form action="{{ route('admin.users.destroy', $user->id) }}" method="POST" class="inline">
@csrf
@method('DELETE')
<x-button type="danger" onclick="return confirm('Are you sure you want to delete this user?')">Delete</x-button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="py-2 px-4 text-center">No users found.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</x-card>
</div>
</x-app-layout>
We conclude Sprint 1 with a system that looks like this: