Build a Task Manager in Raw PHP 8.5
Build a PHP 8.5 task manager with MySQL using OOP, PDO, and raw SQL.
Introduction
30 Second Summary
Every app you use daily stores data somewhere and retrieves it on demand. Frameworks handle this automatically, but knowing what happens underneath is what separates confident developers from the rest.
In this project, you will build a task manager from scratch using raw PHP 8.5 and MySQL. You will write every line of database logic, routing, and rendering by hand so that frameworks never feel like magic again.
What You'll Build
A browser-based task manager that lets you create, view, update, and delete tasks using PHP 8.5 and MySQL.
By the end of this project, you'll have:
- A Composer project with PSR-4 autoloading and a clean folder structure.
- A MySQL database with proper schema design, accessed securely through PDO prepared statements.
- A PHP 8.5 codebase using modern features like enums, readonly classes, and the pipe operator.
- Secret Mission: Add a categories table with foreign keys and filter tasks using a JOIN query.
Are there any prerequisites?
You should be comfortable with programming fundamentals in any language. A basic understanding of how HTTP requests and responses work is also helpful.
Set Up the Project Structure
Before writing any PHP code, you need a properly structured project that Laragon can serve as a local website.
In this step, you'll create the project folder inside Laragon's www directory, configure Composer autoloading, and confirm your browser can reach the project.
In this step, get ready to:
- Create the project folder and directory structure in Laragon's www directory.
- Configure Composer's PSR-4 autoloading for the App namespace.
- Verify Laragon serves the project at taskmanager.test.
Create the project folder and directory structure
Laragon automatically serves any folder inside its www directory as a local website. Creating your project folder there means Laragon will handle the web server configuration for you.
- In Laragon, open the terminal by clicking Menu at the top of the Laragon window, then Laragon, then Terminal.
What is Laragon's terminal?
Laragon bundles a Cmder-based terminal that has PHP, Composer, and MySQL already on its PATH. You don't need to install or configure anything extra to use these tools from this terminal.
- Navigate to Laragon's document root by running this command:
cd C:\laragon\www
- Create the project folder and its subdirectories by running these commands:
mkdir taskmanager
cd taskmanager
mkdir public src templates
What do these folders do?
The public/ folder holds files the web server exposes to browsers. The src/ folder holds your PHP classes. The templates/ folder holds your HTML view files.
- Confirm the folders exist by running:
dir
You should see public, src, and templates listed in the output.
Don't see the folders?
Make sure you ran cd taskmanager before running dir. If C:\laragon\www does not exist, your Laragon installation may use a different drive or folder. Check your Laragon installation path and substitute it.
help me find my Laragon www directory
Configure Composer autoloading
PSR-4 autoloading tells PHP where to find your classes based on their namespace. Instead of manually writing require statements for every file, Composer generates a single autoloader that maps the App\ namespace to your src/ directory.
- Switch to VS Code.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to C:\laragon\www\taskmanager and click Select Folder.
- Create a new file called composer.json in the root of the taskmanager folder by clicking the new file icon in the Explorer sidebar.
- Paste the following into composer.json:
{
"name": "app/task-manager",
"description": "Raw PHP 8.5 Task Manager",
"type": "project",
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"require": {
"php": ">=8.3"
}
}
What does this file do?
The autoload section maps the App\ namespace to the src/ directory. Any class you create under src/ with namespace App will be automatically loadable without manual require statements.
The require section declares that this project needs PHP 8.3 or higher. This prevents Composer from installing packages that would break on older PHP versions.
- Save composer.json.
- Return to the Laragon terminal and generate the autoloader by running:
composer dump-autoload
You should see a message confirming the autoload files were generated. A new vendor/ folder now exists in your project.
Seeing 'composer is not recognized'?
Make sure you are using Laragon's own terminal, not a separate Command Prompt or PowerShell window. Laragon's terminal automatically includes Composer on its PATH.
help me fix Composer not being recognized in my terminal
Verify Laragon serves the project
Laragon's Auto Virtual Hosts feature automatically maps any folder in its www directory to a .test URL. Your taskmanager folder becomes taskmanager.test in the browser.
- In VS Code, create a new file called index.php inside the public/ folder.
- Paste the following into public/index.php:
<?php
echo 'Hello PHP 8.5';
What does this file do?
This is a minimal test page. The public/ folder is the web-accessible root, so index.php is the first file Apache loads when you visit the project URL.
- Save public/index.php.
- In Laragon, click the Reload button on the main Laragon window to register the new virtual host.
- Open your browser and navigate to http://taskmanager.test.
You should see "Hello PHP 8.5" displayed in the browser.
Seeing a blank page or an error?
If you see a directory listing instead of your message, make sure index.php is inside the public/ folder, not in the project root. If the URL doesn't resolve at all, click Reload in Laragon again and wait a few seconds.
If you see the Laragon default page instead of your content, your virtual host may need the document root pointed to public/. Try visiting http://taskmanager.test/index.php directly.
help me get taskmanager.test working in my browser
Why does my URL end in .test?
Laragon uses the .test top-level domain for local projects. This domain is reserved by IANA for testing purposes, so it will never conflict with a real website. Every folder in your www directory automatically gets its own .test URL.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is the complete composer.json:
{
"name": "app/task-manager",
"description": "Raw PHP 8.5 Task Manager",
"type": "project",
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"require": {
"php": ">=8.3"
}
}
Here is the complete public/index.php:
<?php
echo 'Hello PHP 8.5';
Your project structure is ready and Laragon is serving it locally. Next up, you'll design the database schema and connect to MySQL with PDO.
Design the Database and Connect with PDO
Your project structure is ready and Laragon is serving your app at taskmanager.test. Now it's time to give your task manager a place to store data.
Every task needs to persist between page loads. MySQL will hold your tasks in a structured table, and PDO (PHP Data Objects) gives you a secure way to talk to the database from PHP code.
In this step, get ready to:
- Create a MySQL database and tasks table with proper column types and indexes.
- Build a Database connection class using PDO.
- Test the connection to confirm PHP can talk to MySQL.
Create the database and tasks table
Before writing any PHP, you need a database and table for your tasks to live in. You will write the SQL schema in a file so it is reusable and version-controlled.
- In VS Code, create a new file called schema.sql in the root of your taskmanager folder (at the same level as composer.json).
- Add the following SQL to define your database and table:
CREATE DATABASE IF NOT EXISTS task_manager
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE task_manager;
CREATE TABLE tasks (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
priority ENUM('low', 'medium', 'high', 'critical') NOT NULL DEFAULT 'medium',
status ENUM('todo', 'in_progress', 'done') NOT NULL DEFAULT 'todo',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status (status),
INDEX idx_priority (priority)
) ENGINE=InnoDB;
What does this SQL do?
- CREATE DATABASE IF NOT EXISTS creates the task_manager database with utf8mb4 encoding, which supports the full range of Unicode characters (including emoji).
- ENUM columns for priority and status restrict values to a predefined set. The database rejects anything that does not match.
- INDEX idx_status (status) and INDEX idx_priority (priority) create indexes that speed up filtering tasks by status or priority.
- CURRENT_TIMESTAMP auto-fills created_at when a row is inserted. The ON UPDATE CURRENT_TIMESTAMP clause auto-updates updated_at whenever the row changes.
- Save schema.sql.
Now you need to execute this SQL against MySQL so the database and table actually exist.
- In Laragon, click the Terminal button to open the built-in terminal.
- Navigate to your project folder by running:
cd taskmanager
- Execute the schema file against MySQL by running:
mysql -u root < schema.sql
- Confirm the table was created by running:
mysql -u root -e "SHOW TABLES FROM task_manager;"
You should see output listing a tasks table:
+------------------------+
| Tables_in_task_manager |
+------------------------+
| tasks |
+------------------------+
Seeing 'mysql' is not recognized?
This means MySQL is not in your terminal's PATH. Make sure Laragon is running (the green status indicator should be visible). Close and reopen the terminal from Laragon's Terminal button so it picks up the correct PATH.
If you see an access denied error, confirm that Laragon's MySQL uses the default root user with no password. You can check this in Laragon's preferences under the MySQL tab.
help me troubleshoot my MySQL connection in Laragon
✔️ Awesome, I've got everything!
Great. Make sure you have saved schema.sql and confirmed the tasks table exists in MySQL.
ⓧ I'd like to double check the full code
CREATE DATABASE IF NOT EXISTS task_manager
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE task_manager;
CREATE TABLE tasks (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
priority ENUM('low', 'medium', 'high', 'critical') NOT NULL DEFAULT 'medium',
status ENUM('todo', 'in_progress', 'done') NOT NULL DEFAULT 'todo',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status (status),
INDEX idx_priority (priority)
) ENGINE=InnoDB;
Build the Database connection class
Your PHP code needs a reliable way to connect to MySQL. PDO is PHP's built-in database abstraction layer, and wrapping it in a class gives you a single place to manage connection settings.
- Create a new file at src/Database.php and add the following code:
<?php
declare(strict_types=1);
namespace App;
use PDO;
final class Database
{
private static ?PDO $instance = null;
public static function connect(): PDO
{
if (self::$instance === null) {
self::$instance = new PDO(
dsn: 'mysql:host=127.0.0.1;dbname=task_manager;charset=utf8mb4',
username: 'root',
password: '',
options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
],
);
}
return self::$instance;
}
}
What does this code do?
- final class Database prevents other classes from extending this one. It has a single job: provide a PDO connection.
- The $instance property stores the connection so connect() reuses the same PDO object instead of opening a new connection every time. This is the singleton pattern.
- ERRMODE_EXCEPTION makes PDO throw exceptions on errors instead of silently returning false.
- FETCH_ASSOC returns query results as associative arrays (column names as keys).
- ATTR_EMULATE_PREPARES => false forces real server-side prepared statements. This is more secure because MySQL validates parameter types directly.
Why not use localhost?
The DSN uses 127.0.0.1 instead of localhost. On Windows, localhost sometimes tries to connect via a Unix socket (which does not exist on Windows). Using the IP address forces a TCP connection, which is more reliable across environments.
- Save src/Database.php. You will test the connection in the next substep.
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/Database.php.
ⓧ I'd like to double check the full code
<?php
declare(strict_types=1);
namespace App;
use PDO;
final class Database
{
private static ?PDO $instance = null;
public static function connect(): PDO
{
if (self::$instance === null) {
self::$instance = new PDO(
dsn: 'mysql:host=127.0.0.1;dbname=task_manager;charset=utf8mb4',
username: 'root',
password: '',
options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
],
);
}
return self::$instance;
}
}
Test the connection
The Database class looks right, but you need proof that PHP can actually reach MySQL. A quick test script will confirm connectivity before you build on top of it.
- Create a new file called test_db.php in the root of your taskmanager folder (next to composer.json) and add the following code:
<?php
require_once __DIR__ . '/vendor/autoload.php';
use App\Database;
// Attempt to connect and run a test query
$db = Database::connect();
$result = $db->query('SELECT 1 AS connected')->fetch();
echo 'Connected to MySQL! Result: ' . $result['connected'] . "\n";
What does this test do?
The script loads Composer's autoloader so PHP can find the App\Database class. It then calls Database::connect() to open a PDO connection and runs SELECT 1 as a simple query that always succeeds if the connection is working.
- Save test_db.php.
- Run the test script from Laragon's terminal:
php test_db.php
You should see this output:
Connected to MySQL! Result: 1
Seeing a connection error?
If you see Connection refused, make sure MySQL is running in Laragon. Check the Laragon window for a green indicator next to MySQL.
If you see Unknown database 'task_manager', the schema was not executed successfully. Go back and re-run mysql -u root < schema.sql from the taskmanager directory.
help me debug my PDO connection to MySQL
- Once you see the success message, delete test_db.php from your project. It was only needed to verify the connection.
Your database is live and PHP can talk to it securely. Next up, you will build the Task model using PHP 8.5 features like enums, readonly classes, and the pipe operator.
Build the Task Model with PHP 8.5 Features
Your database is connected and ready to go. Now you need a way for PHP to represent that data as objects your application can work with.
In this step, you will build the model layer using modern PHP 8.5 features. You will define enums for task priority and status, create a readonly class that represents a task, and build a repository class that talks to MySQL using PDO prepared statements.
In this step, get ready to:
- Create backed enums for Priority and Status values.
- Build a readonly Task class using constructor promotion and the pipe operator.
- Create a TaskRepository with full CRUD using prepared statements.
Create the Priority and Status enums
PHP enums let you define a fixed set of named values that a field can hold. Instead of passing raw strings like 'low' or 'high' around your code, you get type-safe constants with methods attached.
- Create the directories for your model layer by running these commands in your terminal:
mkdir src/Enum src/Model src/Repository
- Confirm the directories exist by running:
ls src
You should see Enum, Model, and Repository listed in the output.
- Create a new file called Priority.php inside the src/Enum/ folder, then paste the following code:
<?php
declare(strict_types=1);
namespace App\Enum;
enum Priority: string
{
case Low = 'low';
case Medium = 'medium';
case High = 'high';
case Critical = 'critical';
// Returns a human-readable label for display
public function label(): string
{
return match ($this) {
self::Low => 'Low',
self::Medium => 'Medium',
self::High => 'High',
self::Critical => 'Critical',
};
}
// Returns a hex color for UI rendering
public function color(): string
{
return match ($this) {
self::Low => '#28a745',
self::Medium => '#ffc107',
self::High => '#fd7e14',
self::Critical => '#dc3545',
};
}
}
What does this code do?
This defines a backed enum. The : string after Priority means each case stores a string value ('low', 'medium', etc.) that maps directly to the ENUM column in your tasks table.
The label() method gives a display-friendly name, and color() provides a hex color you will use later to color-code priorities in the UI. The match expression is PHP's exhaustive pattern matching, so the compiler warns you if you miss a case.
- Save src/Enum/Priority.php.
You won't see a visible change yet. The next file completes the enum pair.
- Create a new file called Status.php inside the src/Enum/ folder, then paste the following code:
<?php
declare(strict_types=1);
namespace App\Enum;
enum Status: string
{
case Todo = 'todo';
case InProgress = 'in_progress';
case Done = 'done';
// Returns a human-readable label for display
public function label(): string
{
return match ($this) {
self::Todo => 'To Do',
self::InProgress => 'In Progress',
self::Done => 'Done',
};
}
}
What does this code do?
This is the same backed enum pattern as Priority. The Status enum maps each case to the exact string stored in your database's status column. The label() method converts 'in_progress' into the friendlier 'In Progress' for display.
- Save src/Enum/Status.php.
- Verify both enums have valid syntax by running:
php -l src/Enum/Priority.php && php -l src/Enum/Status.php
You should see No syntax errors detected printed for each file.
Seeing a syntax error?
- Check that each file starts with <?php on line 1 with nothing before it (no spaces, no blank lines).
- Make sure every case line ends with a semicolon.
- Confirm the closing } brace matches the opening enum declaration.
Help me fix a PHP enum syntax error.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is src/Enum/Priority.php:
<?php
declare(strict_types=1);
namespace App\Enum;
enum Priority: string
{
case Low = 'low';
case Medium = 'medium';
case High = 'high';
case Critical = 'critical';
public function label(): string
{
return match ($this) {
self::Low => 'Low',
self::Medium => 'Medium',
self::High => 'High',
self::Critical => 'Critical',
};
}
public function color(): string
{
return match ($this) {
self::Low => '#28a745',
self::Medium => '#ffc107',
self::High => '#fd7e14',
self::Critical => '#dc3545',
};
}
}
Here is src/Enum/Status.php:
<?php
declare(strict_types=1);
namespace App\Enum;
enum Status: string
{
case Todo = 'todo';
case InProgress = 'in_progress';
case Done = 'done';
public function label(): string
{
return match ($this) {
self::Todo => 'To Do',
self::InProgress => 'In Progress',
self::Done => 'Done',
};
}
}
Create the Task readonly class
Now you need a class to represent a single task as a PHP object. A readonly class means once a Task is created, its properties cannot be changed. This protects your data from accidental mutations.
This class also uses PHP 8.5's pipe operator (|>) to create a clean factory method that transforms a raw database row into a typed Task object.
- Create a new file called Task.php inside the src/Model/ folder, then paste the following code:
<?php
declare(strict_types=1);
namespace App\Model;
use App\Enum\Priority;
use App\Enum\Status;
readonly class Task
{
// Constructor promotion declares and assigns properties in one step
public function __construct(
public int $id,
public string $title,
public string $description,
public Priority $priority,
public Status $status,
public string $createdAt,
public string $updatedAt,
) {}
// Factory method that hydrates a Task from a database row
public static function fromRow(array $row): self
{
return $row
|> fn(array $r) => new self(
id: (int) $r['id'],
title: $r['title'],
description: $r['description'],
priority: Priority::from($r['priority']),
status: Status::from($r['status']),
createdAt: $r['created_at'],
updatedAt: $r['updated_at'],
);
}
}
What does this code do?
The readonly keyword on the class means every property is immutable after construction. Constructor promotion (public int $id in the constructor signature) declares and assigns each property in a single line instead of writing them out separately.
The fromRow() method uses PHP 8.5's pipe operator (|>). It takes $row (a raw associative array from the database) and pipes it into an arrow function that constructs a new Task. The Priority::from() and Status::from() calls convert the stored strings back into their enum representations.
Why use the pipe operator here?
The pipe operator passes the value on its left as the argument to the callable on its right. In this case it is a simple demonstration, but in larger codebases you chain multiple transformations left-to-right without nesting function calls. It makes data flow read like a sentence: take the row, pipe it through the constructor.
- Save src/Model/Task.php.
- Verify the syntax by running:
php -l src/Model/Task.php
You should see No syntax errors detected in src/Model/Task.php.
Seeing an error about the pipe operator?
- Make sure you are running PHP 8.5. Check with php -v in your terminal. If it shows a version below 8.5, switch to the PHP 8.5 binary in Laragon.
- Verify the |> operator is on its own line, directly after $row.
Help me fix a PHP 8.5 pipe operator error.
✔️ Awesome, I've got everything!
Great. Your Task model is ready. Make sure the file is saved.
ⓧ I'd like to double check the full code
<?php
declare(strict_types=1);
namespace App\Model;
use App\Enum\Priority;
use App\Enum\Status;
readonly class Task
{
public function __construct(
public int $id,
public string $title,
public string $description,
public Priority $priority,
public Status $status,
public string $createdAt,
public string $updatedAt,
) {}
public static function fromRow(array $row): self
{
return $row
|> fn(array $r) => new self(
id: (int) $r['id'],
title: $r['title'],
description: $r['description'],
priority: Priority::from($r['priority']),
status: Status::from($r['status']),
createdAt: $r['created_at'],
updatedAt: $r['updated_at'],
);
}
}
Create the TaskRepository
A repository is a class responsible for all database operations on a specific entity. Your TaskRepository will handle creating, reading, updating, and deleting tasks using PDO prepared statements.
Prepared statements separate the SQL structure from the data values. This prevents SQL injection attacks because user input is never directly interpolated into the query string.
- Create a new file called TaskRepository.php inside the src/Repository/ folder, then paste the following code:
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Enum\Priority;
use App\Enum\Status;
use App\Model\Task;
use PDO;
final class TaskRepository
{
// Accepts a PDO connection via constructor promotion
public function __construct(
private readonly PDO $db,
) {}
/** @return Task[] */
public function findAll(): array
{
// Fetch all tasks ordered by newest first
$stmt = $this->db->query('SELECT * FROM tasks ORDER BY created_at DESC');
$rows = $stmt->fetchAll();
// Convert each raw row into a Task object
return array_map(Task::fromRow(...), $rows);
}
public function findById(int $id): ?Task
{
// Use a prepared statement with a named parameter to prevent SQL injection
$stmt = $this->db->prepare('SELECT * FROM tasks WHERE id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch();
return $row !== false ? Task::fromRow($row) : null;
}
public function create(string $title, string $description, Priority $priority, Status $status): void
{
$stmt = $this->db->prepare(
'INSERT INTO tasks (title, description, priority, status, created_at, updated_at)
VALUES (:title, :description, :priority, :status, NOW(), NOW())'
);
$stmt->execute([
':title' => $title,
':description' => $description,
':priority' => $priority->value,
':status' => $status->value,
]);
}
public function update(int $id, string $title, string $description, Priority $priority, Status $status): void
{
$stmt = $this->db->prepare(
'UPDATE tasks SET title = :title, description = :description, priority = :priority, status = :status, updated_at = NOW()
WHERE id = :id'
);
$stmt->execute([
':id' => $id,
':title' => $title,
':description' => $description,
':priority' => $priority->value,
':status' => $status->value,
]);
}
public function delete(int $id): void
{
$stmt = $this->db->prepare('DELETE FROM tasks WHERE id = :id');
$stmt->execute([':id' => $id]);
}
}
What does this code do?
- findAll() fetches every task and uses array_map(Task::fromRow(...), $rows) to convert each raw row into a typed Task object. The (...) syntax creates a first-class callable from the static method.
- findById() uses a prepared statement with the named parameter :id to safely look up a single task. It returns null if no task matches.
- create() and update() use $priority->value to extract the underlying string from the enum before sending it to the database.
- delete() removes a task by ID using a prepared statement.
Every method that accepts user input uses named parameters (:title, :id) instead of concatenating values into the SQL string. PDO sends the query structure and data separately to MySQL, making SQL injection impossible.
- Save src/Repository/TaskRepository.php.
- Verify the syntax by running:
php -l src/Repository/TaskRepository.php
You should see No syntax errors detected in src/Repository/TaskRepository.php.
Seeing a syntax error in TaskRepository?
- Check that the multi-line SQL strings inside prepare() use single quotes and are not accidentally closed early.
- Make sure every $stmt->execute() call has a matching closing bracket and semicolon.
- Verify that the use imports at the top list all four classes: Priority, Status, Task, and PDO.
Help me debug my TaskRepository syntax error.
✔️ Awesome, I've got everything!
Great. Double check all four new files are saved.
ⓧ I'd like to double check the full code
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Enum\Priority;
use App\Enum\Status;
use App\Model\Task;
use PDO;
final class TaskRepository
{
public function __construct(
private readonly PDO $db,
) {}
/** @return Task[] */
public function findAll(): array
{
$stmt = $this->db->query('SELECT * FROM tasks ORDER BY created_at DESC');
$rows = $stmt->fetchAll();
return array_map(Task::fromRow(...), $rows);
}
public function findById(int $id): ?Task
{
$stmt = $this->db->prepare('SELECT * FROM tasks WHERE id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch();
return $row !== false ? Task::fromRow($row) : null;
}
public function create(string $title, string $description, Priority $priority, Status $status): void
{
$stmt = $this->db->prepare(
'INSERT INTO tasks (title, description, priority, status, created_at, updated_at)
VALUES (:title, :description, :priority, :status, NOW(), NOW())'
);
$stmt->execute([
':title' => $title,
':description' => $description,
':priority' => $priority->value,
':status' => $status->value,
]);
}
public function update(int $id, string $title, string $description, Priority $priority, Status $status): void
{
$stmt = $this->db->prepare(
'UPDATE tasks SET title = :title, description = :description, priority = :priority, status = :status, updated_at = NOW()
WHERE id = :id'
);
$stmt->execute([
':id' => $id,
':title' => $title,
':description' => $description,
':priority' => $priority->value,
':status' => $status->value,
]);
}
public function delete(int $id): void
{
$stmt = $this->db->prepare('DELETE FROM tasks WHERE id = :id');
$stmt->execute([':id' => $id]);
}
}
Your entire model layer is built. You have enums for type safety, a readonly class for immutability, and a repository for secure database access. Next up, you will wire these classes into a front controller that handles incoming HTTP requests and turns them into real actions.
Build the Front Controller and Routing
You have a database, a connection class, enums, a model, and a repository. All the pieces are ready.
Now you need the piece that ties them together: a front controller. This is the single PHP file that receives every HTTP request, decides what the user wants to do, and calls the right repository method. Without it, your classes just sit there unused.
In this step, get ready to:
- Build a front controller that routes requests to handler functions.
- Implement handler functions for listing, creating, editing, and deleting tasks.
- Add input validation and error handling to protect against bad data.
Create the front controller
A front controller is a single entry point for your entire application. Every request hits public/index.php, which reads a query parameter to figure out what the user wants to do.
- In VS Code, open public/index.php from the Explorer sidebar.
- Select all the existing code (the echo 'Hello PHP 8.5' test page) and delete it.
- Replace it with the following code that sets up the router and the list handler:
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use App\Database;
use App\Repository\TaskRepository;
use App\Enum\Priority;
use App\Enum\Status;
// Connect to the database and create the repository
$db = Database::connect();
$taskRepo = new TaskRepository($db);
// Read the requested action from the URL query string
$action = $_GET['action'] ?? 'list';
// Route to the correct handler function
match ($action) {
'create' => handleCreate($taskRepo),
'edit' => handleEdit($taskRepo),
'delete' => handleDelete($taskRepo),
default => handleList($taskRepo),
};
function handleList(TaskRepository $taskRepo): void
{
$tasks = $taskRepo->findAll();
require __DIR__ . '/../templates/list.php';
}
What does this code do?
The top section loads Composer's autoloader so all your App\ classes are available without manual require statements.
The $_GET['action'] ?? 'list' line reads the action parameter from the URL. If none is provided, it defaults to 'list'.
The match() expression uses strict comparison to route the action to the correct function. It replaces the older switch statement with cleaner, safer syntax. No fall-through, no loose comparisons.
What is a front controller?
In a framework like Laravel, routing is handled automatically. Here you are building it by hand so you understand exactly what happens when a user visits a URL. Every framework uses this same front-controller pattern under the hood.
Implement each action
Each handler function is responsible for one CRUD operation. They read form data from $_POST, call the repository, and either render a template or redirect the browser.
- Add the handleCreate() function below handleList() by pasting this code:
function handleCreate(TaskRepository $taskRepo): void
{
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Extract and clean form input
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
$priority = $_POST['priority'] ?? '';
$status = $_POST['status'] ?? '';
// Validate required fields
if ($title === '') {
$errors[] = 'Title is required.';
}
// If no errors, create the task and redirect
if ($errors === []) {
$taskRepo->create($title, $description, Priority::from($priority), Status::from($status));
header('Location: index.php');
exit;
}
}
// Show the form (GET request or failed validation)
$task = null;
require __DIR__ . '/../templates/form.php';
}
What does this code do?
This function handles both showing the create form (GET request) and processing the submission (POST request).
- On GET: it skips the if block entirely, sets $task = null, and renders the form template.
- On POST: it validates the title, creates the task using your repository, and redirects back to the list.
- The header('Location: index.php') redirect prevents duplicate form submissions if the user refreshes.
- Add the handleDelete() function below handleCreate():
function handleDelete(TaskRepository $taskRepo): void
{
// Get the task ID from the URL
$id = (int) ($_GET['id'] ?? 0);
$taskRepo->delete($id);
header('Location: index.php');
exit;
}
What does this code do?
This is the simplest handler. It reads the id from the URL, calls delete() on the repository, and redirects back to the task list. The (int) cast ensures the ID is always an integer, even if someone tampers with the URL.
Add input validation and edge case handling
The edit handler is the most complex because it combines fetching an existing task, validating input, and handling the case where a task does not exist.
- Add the handleEdit() function between handleCreate() and handleDelete():
function handleEdit(TaskRepository $taskRepo): void
{
// Get the task ID and look it up
$id = (int) ($_GET['id'] ?? 0);
$task = $taskRepo->findById($id);
// Return 404 if the task doesn't exist
if ($task === null) {
http_response_code(404);
echo 'Task not found.';
return;
}
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Extract and clean form input
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
$priority = $_POST['priority'] ?? '';
$status = $_POST['status'] ?? '';
// Validate required fields
if ($title === '') {
$errors[] = 'Title is required.';
}
// If no errors, update the task and redirect
if ($errors === []) {
$taskRepo->update($id, $title, $description, Priority::from($priority), Status::from($status));
header('Location: index.php');
exit;
}
}
// Show the form with existing task data
require __DIR__ . '/../templates/form.php';
}
What does this code do?
This handler demonstrates three important validation patterns:
- Existence check: findById() returns null if no task matches. The handler sends a 404 status code and stops.
- Required field validation: just like handleCreate(), the title cannot be empty.
- Type safety: Priority::from() and Status::from() convert raw strings into enum cases. If someone submits an invalid value, PHP throws a ValueError automatically.
Why redirect after POST?
The Post/Redirect/Get (PRG) pattern prevents a common problem. Without the redirect, if a user refreshes the browser after submitting a form, the browser re-sends the POST request and creates a duplicate task.
By redirecting to index.php after a successful create or update, the browser makes a fresh GET request. Refreshing after that just reloads the list.
- Save public/index.php.
- Visit http://taskmanager.test in your browser. You will see an error because the templates do not exist yet. That is expected.
Seeing a 500 error or blank page?
- Check that Laragon is still running with Apache and MySQL active.
- Make sure vendor/autoload.php exists. If not, open Laragon's terminal and run composer dump-autoload inside your project folder.
- Verify the file path is correct. The require_once __DIR__ . '/../vendor/autoload.php' line expects vendor/ to be one level above the public/ directory.
Why is my front controller throwing a 500 error?
✔️ Awesome, I've got everything!
Great. Make sure you have saved public/index.php before moving on.
ⓧ I'd like to double check the full code
Here is the complete public/index.php file. Compare yours against it:
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use App\Database;
use App\Repository\TaskRepository;
use App\Enum\Priority;
use App\Enum\Status;
$db = Database::connect();
$taskRepo = new TaskRepository($db);
$action = $_GET['action'] ?? 'list';
match ($action) {
'create' => handleCreate($taskRepo),
'edit' => handleEdit($taskRepo),
'delete' => handleDelete($taskRepo),
default => handleList($taskRepo),
};
function handleList(TaskRepository $taskRepo): void
{
$tasks = $taskRepo->findAll();
require __DIR__ . '/../templates/list.php';
}
function handleCreate(TaskRepository $taskRepo): void
{
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
$priority = $_POST['priority'] ?? '';
$status = $_POST['status'] ?? '';
if ($title === '') {
$errors[] = 'Title is required.';
}
if ($errors === []) {
$taskRepo->create($title, $description, Priority::from($priority), Status::from($status));
header('Location: index.php');
exit;
}
}
$task = null;
require __DIR__ . '/../templates/form.php';
}
function handleEdit(TaskRepository $taskRepo): void
{
$id = (int) ($_GET['id'] ?? 0);
$task = $taskRepo->findById($id);
if ($task === null) {
http_response_code(404);
echo 'Task not found.';
return;
}
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
$priority = $_POST['priority'] ?? '';
$status = $_POST['status'] ?? '';
if ($title === '') {
$errors[] = 'Title is required.';
}
if ($errors === []) {
$taskRepo->update($id, $title, $description, Priority::from($priority), Status::from($status));
header('Location: index.php');
exit;
}
}
require __DIR__ . '/../templates/form.php';
}
function handleDelete(TaskRepository $taskRepo): void
{
$id = (int) ($_GET['id'] ?? 0);
$taskRepo->delete($id);
header('Location: index.php');
exit;
}
Your routing layer is complete. Next up, you will build the HTML templates that these handler functions require. Once those are in place, you will have a fully working task manager in the browser.
Create the Templates and Complete the UI
Your back-end is complete. The front controller routes requests, the repository talks to MySQL, and the model hydrates typed objects. The missing piece is the view layer that renders HTML for the browser.
In this step, you will build three PHP templates that turn your data into a visual interface. You will use output buffering to inject page content into a shared layout, loop over enum cases to build dropdowns, and test the full create-read-update-delete cycle in the browser.
In this step, get ready to:
- Build a base layout template with shared CSS and a task list view.
- Build a create/edit form that populates dropdowns from enum cases.
- Test the full CRUD flow and verify data persists in MySQL.
Build the layout and list template
PHP does not have a built-in template engine like Blade or Twig. Instead, you use plain .php files with embedded HTML and a technique called output buffering. Output buffering captures everything a template outputs into a variable, which a parent layout then injects into the page structure.
- In VS Code, create a new file called layout.php inside the templates/ folder.
- Paste the following code into templates/layout.php:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Task Manager - PHP 8.5</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; max-width: 900px; margin: 0 auto; padding: 2rem; background: #f5f5f5; }
h1 { margin-bottom: 1.5rem; color: #333; }
a { color: #0066cc; text-decoration: none; }
a:hover { text-decoration: underline; }
.btn { display: inline-block; padding: 0.5rem 1rem; background: #0066cc; color: #fff; border-radius: 4px; border: none; cursor: pointer; font-size: 0.9rem; }
.btn:hover { background: #0052a3; text-decoration: none; }
.btn-danger { background: #dc3545; }
.btn-danger:hover { background: #b02a37; }
.card { background: #fff; border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 12px; font-size: 0.75rem; color: #fff; font-weight: 600; }
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.3rem; font-weight: 600; color: #555; }
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; }
.form-group textarea { height: 100px; resize: vertical; }
.error { color: #dc3545; margin-bottom: 1rem; }
.task-meta { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.5rem; }
.task-actions { margin-top: 0.75rem; display: flex; gap: 0.5rem; }
</style>
</head>
<body>
<h1><a href="index.php">Task Manager</a></h1>
<?= $content ?? '' ?>
</body>
</html>
What does this template do?
This is the shell that wraps every page. It defines the HTML structure, shared CSS classes, and a single injection point: <?= $content ?? '' ?>.
Each page template captures its output into a $content variable using ob_start() and ob_get_clean(), then requires this layout. The layout echoes that variable into the body. This is the same pattern frameworks like Laravel automate with Blade's @yield directive.
- Save templates/layout.php.
Now you need a page that uses this layout. The task list is the default view users see when they visit the app.
- Create a new file called list.php inside the templates/ folder.
- Paste the following code into templates/list.php:
<?php
use App\Enum\Priority;
use App\Enum\Status;
ob_start();
?>
<p style="margin-bottom: 1.5rem;">
<a href="index.php?action=create" class="btn">+ New Task</a>
</p>
<?php if ($tasks === []): ?>
<div class="card">
<p>No tasks yet. Create your first one!</p>
</div>
<?php else: ?>
<?php foreach ($tasks as $task): ?>
<div class="card">
<h3><?= htmlspecialchars($task->title) ?></h3>
<?php if ($task->description !== ''): ?>
<p style="color: #666; margin-top: 0.3rem;"><?= htmlspecialchars($task->description) ?></p>
<?php endif; ?>
<div class="task-meta">
<span class="badge" style="background: <?= $task->priority->color() ?>">
<?= $task->priority->label() ?>
</span>
<span class="badge" style="background: #6c757d;">
<?= $task->status->label() ?>
</span>
<small style="color: #999;">Created: <?= $task->createdAt ?></small>
</div>
<div class="task-actions">
<a href="index.php?action=edit&id=<?= $task->id ?>" class="btn">Edit</a>
<a href="index.php?action=delete&id=<?= $task->id ?>" class="btn btn-danger" onclick="return confirm('Delete this task?')">Delete</a>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?php
$content = ob_get_clean();
require __DIR__ . '/layout.php';
What does this template do?
- ob_start() begins capturing all output instead of sending it to the browser immediately.
- The template loops over the $tasks array (passed in from handleList() in the front controller) and renders each task as a card.
- htmlspecialchars() escapes user input to prevent XSS attacks. Every time you display user-submitted data, you escape it.
- $task->priority->color() calls the color() method on the Priority enum to set the badge background color dynamically.
- ob_get_clean() captures everything output so far into $content, then requires the layout which renders the full page.
- Save templates/list.php.
- Open your browser and navigate to taskmanager.test. You should see a page with a "Task Manager" heading and a blue "+ New Task" button.
Seeing a blank page or an error?
- Make sure both files are saved inside the templates/ folder (not public/ or the project root).
- Check that Laragon is running and the Apache server is started.
- Confirm your filenames match exactly: layout.php and list.php (lowercase, no typos).
Help me debug why my task manager page is blank or showing an error.
Build the create/edit form template
The form template handles two scenarios with one file: creating a new task (when $task is null) and editing an existing one (when $task is populated). It also uses your enums to dynamically populate the priority and status dropdowns.
- Create a new file called form.php inside the templates/ folder.
- Paste the following code into templates/form.php:
<?php
use App\Enum\Priority;
use App\Enum\Status;
$isEdit = $task !== null;
$pageTitle = $isEdit ? 'Edit Task' : 'Create Task';
ob_start();
?>
<h2 style="margin-bottom: 1rem;"><?= $pageTitle ?></h2>
<?php if ($errors !== []): ?>
<div class="error">
<ul>
<?php foreach ($errors as $error): ?>
<li><?= htmlspecialchars($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="card">
<form method="POST">
<div class="form-group">
<label for="title">Title *</label>
<input type="text" id="title" name="title" value="<?= htmlspecialchars($task?->title ?? ($_POST['title'] ?? '')) ?>" required>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description"><?= htmlspecialchars($task?->description ?? ($_POST['description'] ?? '')) ?></textarea>
</div>
<div class="form-group">
<label for="priority">Priority</label>
<select id="priority" name="priority">
<?php foreach (Priority::cases() as $p): ?>
<option value="<?= $p->value ?>" <?= ($task?->priority === $p) ? 'selected' : '' ?>>
<?= $p->label() ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" name="status">
<?php foreach (Status::cases() as $s): ?>
<option value="<?= $s->value ?>" <?= ($task?->status === $s) ? 'selected' : '' ?>>
<?= $s->label() ?>
</option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn"><?= $isEdit ? 'Update Task' : 'Create Task' ?></button>
<a href="index.php" style="margin-left: 1rem;">Cancel</a>
</form>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/layout.php';
What does this template do?
- $task?->title uses PHP's nullsafe operator. If $task is null (create mode), it falls back to the $_POST value so the user does not lose their input after a validation error.
- Priority::cases() returns all enum cases (Low, Medium, High, Critical). The template loops over them to build the dropdown options automatically. If you add a new priority level to the enum, the form updates itself.
- The $errors array comes from the front controller's validation logic. If it is not empty, the template renders each error as a list item above the form.
- Save templates/form.php.
- Return to your browser and click the blue + New Task button. You should see a form with Title, Description, Priority, and Status fields.
Form not loading or showing errors?
- Make sure the file is named exactly form.php (not forms.php or create.php). The front controller requires this exact path.
- If you see a class-not-found error for Priority or Status, confirm that your use statements at the top of form.php match the namespace exactly: App\Enum\Priority and App\Enum\Status.
Help me debug my task manager form template.
✔️ Awesome, I've got everything!
Great. Make sure all three template files are saved before moving on to the CRUD test.
ⓧ I'd like to double check the full code
Here is the full code for each of the three template files.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Task Manager - PHP 8.5</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; max-width: 900px; margin: 0 auto; padding: 2rem; background: #f5f5f5; }
h1 { margin-bottom: 1.5rem; color: #333; }
a { color: #0066cc; text-decoration: none; }
a:hover { text-decoration: underline; }
.btn { display: inline-block; padding: 0.5rem 1rem; background: #0066cc; color: #fff; border-radius: 4px; border: none; cursor: pointer; font-size: 0.9rem; }
.btn:hover { background: #0052a3; text-decoration: none; }
.btn-danger { background: #dc3545; }
.btn-danger:hover { background: #b02a37; }
.card { background: #fff; border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 12px; font-size: 0.75rem; color: #fff; font-weight: 600; }
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.3rem; font-weight: 600; color: #555; }
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; }
.form-group textarea { height: 100px; resize: vertical; }
.error { color: #dc3545; margin-bottom: 1rem; }
.task-meta { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.5rem; }
.task-actions { margin-top: 0.75rem; display: flex; gap: 0.5rem; }
</style>
</head>
<body>
<h1><a href="index.php">Task Manager</a></h1>
<?= $content ?? '' ?>
</body>
</html>
<?php
use App\Enum\Priority;
use App\Enum\Status;
ob_start();
?>
<p style="margin-bottom: 1.5rem;">
<a href="index.php?action=create" class="btn">+ New Task</a>
</p>
<?php if ($tasks === []): ?>
<div class="card">
<p>No tasks yet. Create your first one!</p>
</div>
<?php else: ?>
<?php foreach ($tasks as $task): ?>
<div class="card">
<h3><?= htmlspecialchars($task->title) ?></h3>
<?php if ($task->description !== ''): ?>
<p style="color: #666; margin-top: 0.3rem;"><?= htmlspecialchars($task->description) ?></p>
<?php endif; ?>
<div class="task-meta">
<span class="badge" style="background: <?= $task->priority->color() ?>">
<?= $task->priority->label() ?>
</span>
<span class="badge" style="background: #6c757d;">
<?= $task->status->label() ?>
</span>
<small style="color: #999;">Created: <?= $task->createdAt ?></small>
</div>
<div class="task-actions">
<a href="index.php?action=edit&id=<?= $task->id ?>" class="btn">Edit</a>
<a href="index.php?action=delete&id=<?= $task->id ?>" class="btn btn-danger" onclick="return confirm('Delete this task?')">Delete</a>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?php
$content = ob_get_clean();
require __DIR__ . '/layout.php';
<?php
use App\Enum\Priority;
use App\Enum\Status;
$isEdit = $task !== null;
$pageTitle = $isEdit ? 'Edit Task' : 'Create Task';
ob_start();
?>
<h2 style="margin-bottom: 1rem;"><?= $pageTitle ?></h2>
<?php if ($errors !== []): ?>
<div class="error">
<ul>
<?php foreach ($errors as $error): ?>
<li><?= htmlspecialchars($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="card">
<form method="POST">
<div class="form-group">
<label for="title">Title *</label>
<input type="text" id="title" name="title" value="<?= htmlspecialchars($task?->title ?? ($_POST['title'] ?? '')) ?>" required>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description"><?= htmlspecialchars($task?->description ?? ($_POST['description'] ?? '')) ?></textarea>
</div>
<div class="form-group">
<label for="priority">Priority</label>
<select id="priority" name="priority">
<?php foreach (Priority::cases() as $p): ?>
<option value="<?= $p->value ?>" <?= ($task?->priority === $p) ? 'selected' : '' ?>>
<?= $p->label() ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" name="status">
<?php foreach (Status::cases() as $s): ?>
<option value="<?= $s->value ?>" <?= ($task?->status === $s) ? 'selected' : '' ?>>
<?= $s->label() ?>
</option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn"><?= $isEdit ? 'Update Task' : 'Create Task' ?></button>
<a href="index.php" style="margin-left: 1rem;">Cancel</a>
</form>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/layout.php';
Test the full CRUD flow
Your templates are in place. Time to verify that the entire application works end-to-end by creating, reading, editing, and deleting tasks.
- In your browser, navigate to taskmanager.test.
- Click + New Task.
- Enter Set up CI/CD pipeline in the Title field.
- Enter Configure GitHub Actions for automated testing in the Description field.
- Select High from the Priority dropdown.
- Leave Status as To Do.
- Click Create Task.
You should be redirected back to the task list. Your new task appears as a white card with an orange "High" priority badge.
- Create a second task with any title and set the priority to Critical. Confirm it appears with a red badge.
- Click Edit on your first task.
- Change the Status dropdown to In Progress.
- Click Update Task. Confirm the status badge on the list now says "In Progress".
- Click Delete on the second task. A confirmation dialog appears asking "Delete this task?".
- Click OK to confirm. The task disappears from the list.
Why verify data in HeidiSQL?
Checking the database directly proves that your PHP code is actually writing to MySQL, not just displaying temporary data. It also lets you inspect column values, timestamps, and indexes to confirm your schema is working as designed.
- In Laragon, click the Database button in the toolbar to open HeidiSQL.
- In the left sidebar, expand task_manager and click on the tasks table.
- Click the Data tab. You should see your remaining task with its title, description, priority, status, and timestamps populated.
Task not appearing in HeidiSQL?
- Make sure you are looking at the task_manager database, not a different one.
- Click the refresh button in HeidiSQL to reload the data view.
- If the table is empty, try creating a new task in the browser and checking again. If it still does not appear, verify that src/Database.php uses dbname=task_manager in the DSN string.
Help me figure out why tasks are not being saved to MySQL.
Your task manager is fully functional. You can create, view, edit, and delete tasks in the browser, and all data persists in MySQL. Next up, you will extend this app with categories and foreign key relationships in the Secret Mission.
Secret mission
Add Categories with a JOIN Query
Your task manager works great, but real-world apps rarely have flat data. Extend the database with a categories table, link it to tasks with a foreign key, and write your first JOIN query to fetch related data across tables.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally on your machine through Laragon, so there are no ongoing costs.
Resources you used:
- The taskmanager project folder in Laragon's www directory (PHP files, Composer dependencies, templates).
- The task_manager MySQL database (tasks table, categories table, all task data).
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with the Laravel series project next.
- Your project stays at taskmanager.test whenever Laragon is running.
- Your MySQL data persists between Laragon restarts.
- Laragon, VS Code, and Composer remain installed on your machine.
Pause - I'll come back to this later
Stop the running services to free up memory while keeping all your files and data intact.
- In Laragon, click Stop All to shut down Apache and MySQL.
- Close Laragon entirely if you want to free its system tray resources.
- When you are ready to resume, open Laragon and click Start All. Your project and database will be exactly as you left them.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your code and database.
- Drop the MySQL database by opening Laragon's terminal and running:
mysql -u root -e "DROP DATABASE task_manager;"
- Delete the project folder by running this in Laragon's terminal:
rmdir /s /q C:\laragon\www\taskmanager
- Confirm the folder is gone by checking Laragon's www directory in File Explorer. You should no longer see a taskmanager folder.
- Verify the database is removed by opening HeidiSQL from Laragon. The task_manager database should no longer appear in the left sidebar.
Nice Work!
Nice Work!
You just built a fully functional task manager from scratch using raw PHP 8.5 and MySQL. No framework, no magic, no abstractions hiding the real work.
You've learned how to:
- Connect to MySQL securely using PDO with prepared statements, exception error mode, and emulated prepares disabled.
- Use modern PHP 8.5 features including enums, readonly classes, constructor promotion, typed properties, and the pipe operator.
- Implement full CRUD operations with raw SQL, PSR-4 autoloading via Composer, and a hand-built front controller routing pattern.
- Added a related categories table with a foreign key relationship and used a LEFT JOIN query to filter tasks by category.
Ready to quiz yourself?