PHP Project — Build a Task Manager Application from Scratch
In this tutorial, you will learn about PHP Project. We cover key concepts, practical examples, and best practices to help you master this topic.
This PHP project walks through building a complete task manager application with project organization, task creation and assignment, status tracking with drag-and-drop kanban boards, deadline management, priority levels, activity logging, and user permissions. You will apply advanced PHP patterns including repositories, services, and observers.
What You'll Learn
- Designing a normalized database schema for projects, tasks, and user assignments
- Implementing a repository pattern for clean database abstraction
- Building a service layer for business logic separation
- Creating a kanban board view with drag-and-drop status updates
- Implementing activity logging with an observer pattern
- Adding role-based permissions for project access
- Sending email notifications for task assignments and deadlines
- Building a dashboard with task statistics and charts
Why It Matters
A task manager is significantly more complex than a blog or contact form. It involves multiple related entities (projects, tasks, users, comments, activity logs), state machines (task status transitions), permissions (who can see what), and real-time feedback (notifications). Mastering these patterns prepares you for enterprise application development.
Real-World Use
The DodaZIP development team uses a PHP-based task manager similar to this project to track features, bugs, and releases across 15 team members. The kanban board view shows tasks in Backlog, In Progress, Review, and Done columns. Activity logs provide an audit trail for every change.
Learning Path
flowchart LR A[REST API Project] --> B[Task Manager\nYou are here] B --> C[E-Commerce Project] style B fill:#f90,color:#fff
Database Schema
CREATE TABLE projects (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
owner_id INT NOT NULL,
status ENUM('active', 'archived') DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE project_members (
project_id INT NOT NULL,
user_id INT NOT NULL,
role ENUM('owner', 'manager', 'member') DEFAULT 'member',
PRIMARY KEY (project_id, user_id),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
status ENUM('backlog', 'todo', 'in_progress', 'review', 'done') DEFAULT 'backlog',
priority ENUM('low', 'medium', 'high', 'urgent') DEFAULT 'medium',
assigned_to INT,
created_by INT NOT NULL,
due_date DATE,
position INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (assigned_to) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id)
);
CREATE TABLE activity_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
task_id INT,
user_id INT NOT NULL,
action VARCHAR(50) NOT NULL,
details TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
Step 1: Task Repository
Create src/Repository/TaskRepository.php:
<?php
namespace App\Repository;
use PDO;
class TaskRepository
{
public function __construct(
private PDO $db
) {}
public function findByProject(int $projectId, string $status = null): array
{
$sql = 'SELECT t.*, u.username as assigned_name, c.username as creator_name
FROM tasks t
LEFT JOIN users u ON t.assigned_to = u.id
LEFT JOIN users c ON t.created_by = c.id
WHERE t.project_id = :project_id';
$params = ['project_id' => $projectId];
if ($status !== null) {
$sql .= ' AND t.status = :status';
$params['status'] = $status;
}
$sql .= ' ORDER BY t.position ASC, t.created_at DESC';
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
public function findById(int $id): ?array
{
$stmt = $this->db->prepare(
'SELECT t.*, u.username as assigned_name
FROM tasks t
LEFT JOIN users u ON t.assigned_to = u.id
WHERE t.id = :id'
);
$stmt->execute(['id' => $id]);
$task = $stmt->fetch();
return $task ?: null;
}
public function create(array $data, int $userId): int
{
$stmt = $this->db->prepare(
'INSERT INTO tasks (project_id, title, description, priority, assigned_to, created_by, due_date, position)
VALUES (:project_id, :title, :description, :priority, :assigned_to, :created_by, :due_date, :position)'
);
$stmt->execute([
'project_id' => $data['project_id'],
'title' => $data['title'],
'description' => $data['description'] ?? '',
'priority' => $data['priority'] ?? 'medium',
'assigned_to' => $data['assigned_to'] ?? null,
'created_by' => $userId,
'due_date' => $data['due_date'] ?? null,
'position' => $data['position'] ?? 0,
]);
return (int)$this->db->lastInsertId();
}
public function updateStatus(int $id, string $status): bool
{
$stmt = $this->db->prepare('UPDATE tasks SET status = :status WHERE id = :id');
return $stmt->execute(['status' => $status, 'id' => $id]);
}
public function updatePosition(int $id, int $position, string $status): bool
{
$stmt = $this->db->prepare('UPDATE tasks SET position = :position, status = :status WHERE id = :id');
return $stmt->execute(['position' => $position, 'status' => $status, 'id' => $id]);
}
public function getStatusCounts(int $projectId): array
{
$stmt = $this->db->prepare(
'SELECT status, COUNT(*) as count FROM tasks WHERE project_id = :project_id GROUP BY status'
);
$stmt->execute(['project_id' => $projectId]);
$results = $stmt->fetchAll();
$counts = array_fill_keys(['backlog', 'todo', 'in_progress', 'review', 'done'], 0);
foreach ($results as $row) {
$counts[$row['status']] = (int)$row['count'];
}
return $counts;
}
}
Step 2: Task Service
Create src/Service/TaskService.php:
<?php
namespace App\Service;
use App\Repository\TaskRepository;
class TaskService
{
public function __construct(
private TaskRepository $repository,
private ActivityLogger $logger
) {}
public function createTask(array $data, int $userId): array
{
$errors = [];
if (empty($data['title'])) {
$errors[] = 'Task title is required';
}
if (strlen($data['title']) > 255) {
$errors[] = 'Title must be under 255 characters';
}
if (empty($data['project_id'])) {
$errors[] = 'Project ID is required';
}
if (!empty($errors)) {
return ['success' => false, 'errors' => $errors];
}
$taskId = $this->repository->create($data, $userId);
$this->logger->log(
$data['project_id'],
$taskId,
$userId,
'task_created',
"Task \"{$data['title']}\" was created"
);
$task = $this->repository->findById($taskId);
return ['success' => true, 'task' => $task];
}
public function updateTaskStatus(int $taskId, string $newStatus, int $userId): array
{
$task = $this->repository->findById($taskId);
if (!$task) {
return ['success' => false, 'errors' => ['Task not found']];
}
$validStatuses = ['backlog', 'todo', 'in_progress', 'review', 'done'];
if (!in_array($newStatus, $validStatuses)) {
return ['success' => false, 'errors' => ['Invalid status']];
}
$this->repository->updateStatus($taskId, $newStatus);
$this->logger->log(
$task['project_id'],
$taskId,
$userId,
'status_changed',
"Status changed from \"{$task['status']}\" to \"$newStatus\""
);
$updated = $this->repository->findById($taskId);
return ['success' => true, 'task' => $updated];
}
}
Step 3: Kanban Board View
Create views/kanban.php:
<!DOCTYPE html>
<html>
<head>
<title>Kanban Board - <?= htmlspecialchars($project['name']) ?></title>
<style>
.kanban-board { display: flex; gap: 16px; overflow-x: auto; padding: 16px; }
.kanban-column {
min-width: 280px; max-width: 320px;
background: #f5f5f5; border-radius: 8px;
padding: 12px;
}
.kanban-column h3 {
margin: 0 0 12px 0; font-size: 14px; text-transform: uppercase;
color: #555;
}
.kanban-card {
background: white; border-radius: 6px;
padding: 12px; margin-bottom: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
cursor: grab;
}
.kanban-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
.priority-high { border-left: 3px solid #e74c3c; }
.priority-urgent { border-left: 3px solid #c0392b; background: #fdf0ef; }
.priority-medium { border-left: 3px solid #f39c12; }
.priority-low { border-left: 3px solid #27ae60; }
.kanban-card .title { font-weight: 500; margin-bottom: 4px; }
.kanban-card .meta { font-size: 12px; color: #888; }
</style>
</head>
<body>
<h1><?= htmlspecialchars($project['name']) ?> - Kanban Board</h1>
<div class="kanban-board" id="kanban-board">
<?php
$statuses = ['backlog' => 'Backlog', 'todo' => 'To Do', 'in_progress' => 'In Progress', 'review' => 'Review', 'done' => 'Done'];
$tasksByStatus = [];
foreach ($tasks as $task) {
$tasksByStatus[$task['status']][] = $task;
}
?>
<?php foreach ($statuses as $statusKey => $statusLabel): ?>
<div class="kanban-column" data-status="<?= $statusKey ?>">
<h3><?= $statusLabel ?> (<?= count($tasksByStatus[$statusKey] ?? []) ?>)</h3>
<?php foreach ($tasksByStatus[$statusKey] ?? [] as $task): ?>
<div class="kanban-card priority-<?= htmlspecialchars($task['priority']) ?>"
data-task-id="<?= $task['id'] ?>"
draggable="true">
<div class="title"><?= htmlspecialchars($task['title']) ?></div>
<div class="meta">
<?php if ($task['assigned_name']): ?>
Assigned to: <?= htmlspecialchars($task['assigned_name']) ?>
<?php endif; ?>
<?php if ($task['due_date']): ?>
| Due: <?= $task['due_date'] ?>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<script>
const board = document.getElementById('kanban-board');
let draggedCard = null;
board.addEventListener('dragstart', (e) => {
draggedCard = e.target.closest('.kanban-card');
if (draggedCard) e.dataTransfer.effectAllowed = 'move';
});
board.addEventListener('dragover', (e) => {
e.preventDefault();
const column = e.target.closest('.kanban-column');
if (column) column.style.background = '#e8e8e8';
});
board.addEventListener('dragleave', (e) => {
const column = e.target.closest('.kanban-column');
if (column) column.style.background = '';
});
board.addEventListener('drop', (e) => {
e.preventDefault();
const column = e.target.closest('.kanban-column');
if (!column || !draggedCard) return;
column.style.background = '';
const newStatus = column.dataset.status;
const taskId = draggedCard.dataset.taskId;
fetch('/api/tasks/update-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId, status: newStatus }),
})
.then(response => response.json())
.then(data => {
if (data.success) {
column.appendChild(draggedCard);
} else {
alert('Failed to update task status');
}
});
});
</script>
</body>
</html>
Common Mistakes
- Missing task position tracking: Without a position column, tasks within the same status column appear in arbitrary order, and drag-and-drop reordering has no effect.
- Not validating status transitions: Allowing a task to go from backlog directly to done skips the review step. Implement a state machine that enforces valid transitions.
- Ignoring database transactions: When creating a task and logging the activity, both operations should succeed or fail together. Wrap them in a Transaction.
- Not handling timezones: Due dates without timezone information cause confusion in distributed teams. Store UTC timestamps and convert to the user's timezone for display.
- Forgetting to clean up old activity logs: Activity logs grow quickly. Implement a retention policy that archives or deletes logs older than a configurable period.
Practice Questions
Why use a repository pattern for database access?
- Repositories abstract database queries behind a clean interface. Controllers and services call repository methods instead of writing SQL directly, making the code testable and maintainable.
How does the kanban drag-and-drop update work?
- The JavaScript drag-and-drop API captures the drop event, extracts the task ID and target status, and sends an AJAX POST request to the server to update the database.
Why separate the service layer from the controller?
- Controllers handle HTTP concerns (request Parsing, response formatting). Services contain business logic (validation, notifications, activity logging). This separation keeps both layers testable.
What is the purpose of activity logging?
- Activity logging provides an audit trail of all changes. It answers questions like "who changed this task status and when?" without needing database-level auditing.
Challenge: Implement a recurring task system where certain tasks (e.g., "Weekly backup") automatically re-create themselves after completion. Use a cron job to check for and create recurring tasks daily.
Mini Project
Add a Gantt chart view to the task manager:
- Each task has a start_date and end_date in addition to due_date.
- Display tasks on a timeline grouped by project phase or milestone.
- Highlight overdue tasks in red.
- Show dependencies: a task cannot start until its predecessor is marked done.
- Print the Gantt chart or export it as a PDF.
FAQ
What's Next
Now build a more complex application: an E-Commerce Project with products, carts, orders, and payments.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro