Skip to content

Lab 3: Building an Admin Panel

In this lab, you will build the foundation of an admin panel for your e-commerce application. You will create a dashboard controller and model that query real statistics from your database, set up a route group to organize all admin routes under the /admin prefix, and build a reusable admin layout with sidebar navigation. By the end of this lab, you will have a professional-looking admin dashboard ready for CRUD operations in future labs.


By completing this lab, you will:

  • Use Slim 4 route groups to organize related routes under a common prefix.
  • Build a dashboard controller that queries aggregate statistics from the database.
  • Use the BaseModel’s count() method to retrieve summary data.
  • Create a reusable admin layout with header, sidebar navigation, and footer.
  • Understand the data flow from controller to view using the $data array.
  • Apply htmlspecialchars() for secure output in views.

Before starting this lab, ensure you have:

  • Completed Lab 2: Master-Details with MVC, or an equivalent working Slim 4 application built on the MVC starter project.
  • The BaseModel and BaseController classes set up.
  • Your e-commerce database created, and its name set in config/settings.dev.php.

In this part, you will create the categories and products tables in your database. These tables are needed for the admin dashboard to query statistics from.

Objective: Create the categories table to organize products into logical groups.

Instructions:

  1. Open phpMyAdmin and select your e-commerce database
  2. Go to the SQL tab and write a CREATE TABLE statement for the categories table with the following columns:
ColumnTypeConstraintsDescription
idINTPRIMARY KEY, AUTO_INCREMENTCategory ID
nameVARCHAR(100)UNIQUE, NOT NULLCategory name
descriptionTEXTNULLCategory description
created_atDATETIMEDEFAULT CURRENT_TIMESTAMPCreation timestamp
  1. Run the query and verify the table was created by checking the left sidebar in phpMyAdmin

Objective: Create the products table to store product information.

Instructions:

  1. In phpMyAdmin, go to the SQL tab and write a CREATE TABLE statement for the products table with the following columns:
ColumnTypeConstraintsDescription
idINTPRIMARY KEY, AUTO_INCREMENTProduct ID
category_idINTFOREIGN KEY → categories(id)Category this product belongs to
nameVARCHAR(100)NOT NULLProduct name
descriptionTEXTNULLProduct details
priceDECIMAL(10,2)NOT NULLProduct price
stock_quantityINTDEFAULT 0Units available
created_atDATETIMEDEFAULT CURRENT_TIMESTAMPCreation date
updated_atDATETIMEON UPDATE CURRENT_TIMESTAMPLast update date
  1. Run the query and verify the table was created and that the foreign key relationship to categories is in place

Objective: Add some sample data so the dashboard has something to display.

Instructions:

  1. Insert a few categories:
INSERT INTO categories (name, description) VALUES
('Electronics', 'Electronic devices and accessories'),
('Books', 'Physical and digital books'),
('Clothing', 'Apparel and fashion items');
  1. Insert a few products:
INSERT INTO products (category_id, name, description, price, stock_quantity) VALUES
(1, 'Wireless Mouse', 'Ergonomic wireless mouse', 29.99, 50),
(1, 'USB-C Hub', '7-in-1 USB-C adapter', 49.99, 3),
(2, 'PHP in Action', 'A comprehensive guide to PHP', 39.99, 15),
(3, 'Developer T-Shirt', '100% cotton, black', 24.99, 0);

Objective: Create a model that queries aggregate statistics from the database for the admin dashboard.

Instructions:

  1. Navigate to your app/Domain/Models/ directory

  2. Create a new file named AdminDashboardModel.php

  3. Set up the class:

    • Use the namespace App\Domain\Models
    • Import App\Helpers\Core\PDOService
    • Create a class AdminDashboardModel that extends BaseModel
    • Add a constructor that accepts a PDOService parameter and passes it to the parent constructor
  4. Implement the following two methods in the class:

    1. getTotalProducts(): int: Returns the total number of products in the database. Use $this->count() with a SELECT COUNT(*) query on the products table.

    2. getTotalCategories(): int: Returns the total number of categories. Follow the same pattern as getTotalProducts() but query the categories table.

  5. Save the file

Hints:


Step 5: Create the AdminDashboardController

Section titled “Step 5: Create the AdminDashboardController”

Objective: Create a controller that uses the AdminDashboardModel to fetch statistics and render the dashboard view.

Instructions:

  1. Navigate to your app/Controllers/ directory
  2. Create a new file named AdminDashboardController.php
  3. Create a class named AdminDashboardController that extends BaseController
  4. The constructor should accept a Container instance (passed to the parent constructor) and an AdminDashboardModel instance (stored as a property via dependency injection), following the same pattern you used for ShopsController in Lab 2
  5. Implement the following method, using the standard Slim callback signature (Request $request, Response $response, array $args): Response:
MethodWhat It Should Do
dashboard()Call the model’s getTotalProducts() and getTotalCategories() methods. Build a $data array with the keys listed below. Render the admin/dashboardView.php view by calling $this->render($response, 'admin/dashboardView.php', $data) and return the response. The $data array must be passed as the third argument, otherwise the view will not have access to the statistics.

The $data array must contain the following keys:

KeyValue
title'Admin Dashboard'
totalProductsThe value returned by getTotalProducts()
totalCategoriesThe value returned by getTotalCategories()
username$_SESSION['username'] ?? 'Admin'
  1. Save the file

Required use imports:

  • App\Domain\Models\AdminDashboardModel
  • DI\Container
  • Psr\Http\Message\ResponseInterface as Response
  • Psr\Http\Message\ServerRequestInterface as Request

Hints:

  • $_SESSION['username'] ?? 'Admin' safely reads the username and falls back to 'Admin' if no session value is set. Since authentication is added in a later lab, you will see 'Admin' for now.
  • The key names in $data matter: the view in Step 8 expects exactly $title, $totalProducts, $totalCategories, and $username. Each key becomes a variable of the same name in the view, but only if you pass $data to render().
  • Refer to the BaseController documentation for the render pattern

Objective: Create a route group for all admin routes under the /admin prefix.

Instructions:

  1. Open your app/Routes/web-routes.php file
  2. Add the following import at the top of the file:
use App\Controllers\AdminDashboardController;
  1. Add the admin route group inside the return static function (Slim\App $app): void { ... } closure, after the existing routes:
app/Routes/web-routes.php
// Admin Panel Routes
$app->group('/admin', function ($group) {
// Dashboard route
$group->get('/dashboard', [AdminDashboardController::class, 'dashboard'])
->setName('admin.dashboard');
// TODO (future labs): Add CRUD routes for products, categories, orders, users
// $group->get('/products', [ProductsController::class, 'index'])
// ->setName('admin.products.index');
// $group->get('/categories', [CategoriesController::class, 'index'])
// ->setName('admin.categories.index');
// $group->get('/orders', [OrdersController::class, 'index'])
// ->setName('admin.orders.index');
// $group->get('/users', [UsersController::class, 'index'])
// ->setName('admin.users.index');
});
// Note: Later you'll add ->add(AdminAuthMiddleware::class) to protect these routes
  1. Save the file

Routes Created:

MethodURIController MethodRoute Name
GET/admin/dashboardAdminDashboardController::dashboard()admin.dashboard

Section titled “Step 7: Create the Admin Layout (Header + Footer)”

Objective: Create a reusable admin layout with a sidebar navigation and top bar that can be shared across all admin views.

Instructions:

  1. Navigate to your app/Views/ directory
  2. Create a new folder named admin/ (if it doesn’t already exist)
  1. Inside app/Views/admin/, create a file named adminHeader.php
  2. Copy and paste the following code:
app/Views/admin/adminHeader.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($page_title) ?></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<style>
.admin-sidebar {
min-height: 100vh;
background-color: #212529;
}
.admin-sidebar .nav-link {
color: #adb5bd;
padding: 0.75rem 1rem;
border-radius: 0.25rem;
margin-bottom: 0.25rem;
}
.admin-sidebar .nav-link:hover,
.admin-sidebar .nav-link.active {
color: #fff;
background-color: #495057;
}
.admin-content {
background-color: #f8f9fa;
min-height: 100vh;
}
.stat-card {
transition: transform 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
}
</style>
</head>
<body>
<div class="container-fluid">
<div class="row">
<!-- Sidebar -->
<nav class="col-md-3 col-lg-2 d-md-block admin-sidebar p-0">
<div class="p-3">
<h5 class="text-white mb-0">Admin Panel</h5>
<small class="text-muted">E-Commerce Manager</small>
</div>
<hr class="text-secondary my-0">
<ul class="nav flex-column px-2 mt-2">
<li class="nav-item">
<a class="nav-link active" href="<?= APP_BASE_URL ?>/admin/dashboard">
Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?= APP_BASE_URL ?>/admin/products">
Products
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?= APP_BASE_URL ?>/admin/categories">
Categories
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?= APP_BASE_URL ?>/admin/orders">
Orders
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?= APP_BASE_URL ?>/admin/users">
Users
</a>
</li>
</ul>
<hr class="text-secondary">
<div class="px-2">
<a class="nav-link text-muted" href="<?= APP_BASE_URL ?>/">
&larr; Back to Store
</a>
</div>
</nav>
<!-- Main Content -->
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 admin-content">
<!-- Top bar -->
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><?= htmlspecialchars($page_title) ?></h1>
<div class="text-muted">
Logged in as: <strong><?= htmlspecialchars($username) ?></strong>
</div>
</div>
<?php // Flash messages will be rendered here in a later lab. ?>
<!-- Page content starts here -->
  1. Save the file
  1. Inside app/Views/admin/, create a file named adminFooter.php
  2. Copy and paste the following code:
app/Views/admin/adminFooter.php
<!-- Page content ends here -->
</main>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
</body>
</html>
  1. Save the file

What you just created:

  • adminHeader.php: A reusable layout with a dark sidebar (navigation links to all admin sections), a top bar showing the page title and logged-in username, and a flash messages area.
  • adminFooter.php: Closes the HTML structure and includes Bootstrap JavaScript.
  • Together, they wrap any admin page content.
  1. Open app/Helpers/ViewHelper.php
  2. Add the following two methods to the class:
public static function loadAdminHeader(string $page_title, string $username = 'Admin'): void
{
require_once APP_VIEWS_PATH . '/admin/adminHeader.php';
}
public static function loadAdminFooter(): void
{
require_once APP_VIEWS_PATH . '/admin/adminFooter.php';
}
  1. Save the file

Objective: Create the dashboard view that displays e-commerce statistics using the data passed from the controller.

Instructions:

  1. Inside app/Views/admin/, create a file named dashboardView.php
  2. Copy and paste the following code:
app/Views/admin/dashboardView.php
<?php
use App\Helpers\ViewHelper;
ViewHelper::loadAdminHeader($title, $username);
?>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-xl-3 col-md-6 mb-3">
<div class="card stat-card border-primary">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Total Products</h6>
<h3 class="mb-0"><?= htmlspecialchars($totalProducts) ?></h3>
</div>
<div class="text-primary fs-1">&#128230;</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-3">
<div class="card stat-card border-warning">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Categories</h6>
<h3 class="mb-0"><?= htmlspecialchars($totalCategories) ?></h3>
</div>
<div class="text-warning fs-1">&#128193;</div>
</div>
</div>
</div>
</div>
</div>
<?php ViewHelper::loadAdminFooter(); ?>
  1. Save the file

What you just created:

  • A dashboard view that includes the reusable admin header and footer
  • Two stat cards showing totals for products and categories

Key PHP features used in this view:

FeatureExamplePurpose
ViewHelperViewHelper::loadAdminHeader($title, $username)Reusable layout pattern
htmlspecialchars()htmlspecialchars($totalProducts)Prevent XSS attacks

  1. Start your server and visit http://localhost/your-app-name/admin/dashboard in your browser (replace your-app-name with your project’s folder name in htdocs/).
  2. You should see the dark sidebar, the “Admin Dashboard” title in the top bar, “Logged in as: Admin”, and two stat cards.
  3. With the sample data from Step 3, the cards should show 4 total products and 3 categories.
  4. Add another row to the products table in phpMyAdmin and refresh the page. The product count should increase.

If you get an error, check the following in order: the route group is inside the closure in web-routes.php, the controller imports AdminDashboardModel, the model’s SQL table names match your database, and the view calls loadAdminHeader($title, $username).


Request: GET /admin/dashboard
↓
Route Group (/admin)
↓
AdminDashboardController::dashboard()
↓
AdminDashboardModel (queries database statistics)
↓
dashboardView.php (renders HTML with stats)
↓
Response sent to browser