This mini project extends what you built in intermediate – a multi-user app with proper role separation, middleware stacks, and domain services. Plain PHP, no framework. Storage can be MySQL (from intermediate) or JSON files if you want to focus on structure without schema work.
What you are building
- Three roles:
viewer,editor,admin - Editors create and edit documents; viewers read only; admins manage users and roles
- Middleware pipeline: session, CSRF, auth, role gate per route group
- Domain services for “promote user”, “archive document” – not fat controllers
- Audit log (who did what, when) – append-only file or table row
Route map
GET /login, POST /login, POST /logout
GET /documents [auth: viewer+]
GET /documents/create [auth: editor+]
POST /documents [auth: editor+, csrf]
GET /documents/{id}/edit [auth: editor+, owner or admin]
POST /documents/{id} [auth: editor+, csrf]
GET /admin/users [auth: admin]
POST /admin/users/{id}/role [auth: admin, csrf]
Role middleware
final class RequireRole implements Middleware
{
/** @param list<string> $allowed */
public function __construct(private array $allowed) {}
public function handle(Request $request, callable $next): void
{
$role = $_SESSION['role'] ?? '';
if (!in_array($role, $this->allowed, true)) {
http_response_code(403);
echo 'Forbidden';
return;
}
$next();
}
}
Promote user use case
final class ChangeUserRole
{
public function __construct(
private UserRepository $users,
private AuditLog $audit,
) {}
public function handle(int $targetId, string $newRole, int $actorId): void
{
if (!in_array($newRole, ['viewer', 'editor', 'admin'], true)) {
throw new InvalidArgumentException('Invalid role.');
}
if ($targetId === $actorId) {
throw new ForbiddenException('Cannot change your own role.');
}
$this->users->updateRole($targetId, $newRole);
$this->audit->record($actorId, 'user.role_changed', ['target' => $targetId, 'role' => $newRole]);
}
}
Build order
- 1. Copy intermediate project layout; add
Domain/,Application/folders - 2. Implement pipeline and register global + route middleware
- 3. Seed three test users (one per role)
- 4. Documents CRUD with owner checks in the use case layer
- 5. Admin user list and role change form
- 6. Audit log on role changes and document deletes
- 7. PHPUnit tests for
ChangeUserRoleand document permissions
Done when
A viewer hitting /documents/create gets 403. An editor cannot promote themselves. Admin actions appear in the audit log. You can explain where each rule lives without opening a controller.

