Форма создания/редактирования задачи​

PHP:
<?php
// app/Views/tasks/create.php
ob_start();
?>

<div class="row justify-content-center">
    <div class="col-md-8">
        <div class="card">
            <div class="card-header">
                <h4 class="card-title mb-0"><?= isset($task) ? 'Редактирование задачи' : 'Новая задача' ?></h4>
            </div>
            <div class="card-body">
                <form method="post" action="<?= isset($task) ? "/tasks/{$task->id}/update" : '/tasks/store' ?>">
                    <?php if (!empty($errors)): ?>
                        <div class="alert alert-danger">
                            <ul class="mb-0">
                                <?php foreach ($errors as $field => $fieldErrors): ?>
                                    <?php foreach ($fieldErrors as $error): ?>
                                        <li><?= $error ?></li>
                                    <?php endforeach; ?>
                                <?php endforeach; ?>
                            </ul>
                        </div>
                    <?php endif; ?>

                    <div class="mb-3">
                        <label for="title" class="form-label">Название *</label>
                        <input type="text" class="form-control <?= isset($errors['title']) ? 'is-invalid' : '' ?>"
                               id="title" name="title"
                               value="<?= htmlspecialchars($old['title'] ?? $task->title ?? '') ?>"
                               required>
                        <?php if (isset($errors['title'])): ?>
                            <div class="invalid-feedback">
                                <?= implode('<br>', $errors['title']) ?>
                            </div>
                        <?php endif; ?>
                    </div>

                    <div class="mb-3">
                        <label for="description" class="form-label">Описание *</label>
                        <textarea class="form-control <?= isset($errors['description']) ? 'is-invalid' : '' ?>"
                                  id="description" name="description" rows="4"
                                  required><?= htmlspecialchars($old['description'] ?? $task->description ?? '') ?></textarea>
                        <?php if (isset($errors['description'])): ?>
                            <div class="invalid-feedback">
                                <?= implode('<br>', $errors['description']) ?>
                            </div>
                        <?php endif; ?>
                    </div>

                    <div class="row">
                        <div class="col-md-6 mb-3">
                            <label for="status" class="form-label">Статус</label>
                            <select class="form-select" id="status" name="status">
                                <option value="pending" <?= ($old['status'] ?? $task->status ?? '') == 'pending' ? 'selected' : '' ?>>В ожидании</option>
                                <option value="in_progress" <?= ($old['status'] ?? $task->status ?? '') == 'in_progress' ? 'selected' : '' ?>>В работе</option>
                                <option value="completed" <?= ($old['status'] ?? $task->status ?? '') == 'completed' ? 'selected' : '' ?>>Завершено</option>
                            </select>
                        </div>

                        <div class="col-md-6 mb-3">
                            <label for="priority" class="form-label">Приоритет</label>
                            <select class="form-select" id="priority" name="priority">
                                <option value="low" <?= ($old['priority'] ?? $task->priority ?? '') == 'low' ? 'selected' : '' ?>>Низкий</option>
                                <option value="medium" <?= ($old['priority'] ?? $task->priority ?? '') == 'medium' ? 'selected' : '' ?>>Средний</option>
                                <option value="high" <?= ($old['priority'] ?? $task->priority ?? '') == 'high' ? 'selected' : '' ?>>Высокий</option>
                            </select>
                        </div>
                    </div>

                    <div class="mb-3">
                        <label for="due_date" class="form-label">Срок выполнения</label>
                        <input type="date" class="form-control" id="due_date" name="due_date"
                               value="<?= $old['due_date'] ?? $task->due_date ?? '' ?>">
                        <div class="form-text">Оставьте пустым, если срок не установлен</div>
                    </div>

                    <div class="mb-4">
                        <label class="form-label">Метки</label>
                        <div class="row">
                            <?php foreach ($tags as $tag): ?>
                                <div class="col-md-3 mb-2">
                                    <div class="form-check">
                                        <input class="form-check-input" type="checkbox"
                                               name="tags[]" value="<?= $tag->id ?>"
                                               id="tag-<?= $tag->id ?>"
                                               <?php
                                               if (isset($task)) {
                                                   $taskTags = array_map(function($t) { return $t->id; }, $task->tags());
                                                   echo in_array($tag->id, $taskTags) ? 'checked' : '';
                                               } elseif (isset($old['tags']) && in_array($tag->id, $old['tags'])) {
                                                   echo 'checked';
                                               }
                                               ?>>
                                        <label class="form-check-label" for="tag-<?= $tag->id ?>">
                                            <span class="badge" <?= $tag->getColorStyle() ?>>
                                                <?= htmlspecialchars($tag->name) ?>
                                            </span>
                                        </label>
                                    </div>
                                </div>
                            <?php endforeach; ?>
                        </div>
                    </div>

                    <div class="d-flex justify-content-between">
                        <a href="/tasks" class="btn btn-secondary">Отмена</a>
                        <button type="submit" class="btn btn-primary">
                            <?= isset($task) ? 'Обновить задачу' : 'Создать задачу' ?>
                        </button>
                    </div>
                </form>
            </div>
        </div>
    </div>
</div>

<?php
$content = ob_get_clean();
$title = isset($task) ? 'Редактирование задачи' : 'Новая задача';
include __DIR__ . '/../layouts/main.php';
?>

6. Добавление AJAX​

JavaScript для интерактивности​

JavaScript:
// public/js/app.js
$(document).ready(function() {
    // Поиск задач
    let searchTimeout;
    $('#search-input').on('input', function() {
        clearTimeout(searchTimeout);
        const query = $(this).val().trim();
       
        if (query.length < 2) {
            $('#search-results').hide();
            return;
        }
       
        searchTimeout = setTimeout(function() {
            $.ajax({
                url: '/tasks/search',
                method: 'GET',
                data: { q: query },
                success: function(response) {
                    if (response.tasks.length > 0) {
                        let html = '<div class="list-group">';
                        response.tasks.forEach(function(task) {
                            html += `
                                <a href="/tasks/${task.id}" class="list-group-item list-group-item-action">
                                    <div class="d-flex w-100 justify-content-between">
                                        <h6 class="mb-1">${task.title}</h6>
                                        ${task.status_badge}
                                    </div>
                                    <div class="d-flex justify-content-between">
                                        <small>Приоритет: ${task.priority_badge}</small>
                                        <small>Срок: ${task.due_date || '—'}</small>
                                    </div>
                                    ${task.is_overdue ? '<small class="text-danger"><i class="bi bi-exclamation-triangle"></i> Просрочено</small>' : ''}
                                </a>
                            `;
                        });
                        html += '</div>';
                        $('#search-results-body').html(html);
                        $('#search-results').show();
                    } else {
                        $('#search-results-body').html('<p class="text-muted">Задачи не найдены</p>');
                        $('#search-results').show();
                    }
                },
                error: function() {
                    $('#search-results-body').html('<p class="text-danger">Ошибка поиска</p>');
                    $('#search-results').show();
                }
            });
        }, 300);
    });

    // Изменение статуса задачи
    $('.status-select').on('click', function() {
        const taskId = $(this).data('task-id');
        const currentStatus = $(this).find('.badge').text().trim();
       
        $('#task-id').val(taskId);
       
        // Устанавливаем текущий статус в селект
        const statusMap = {
            'В ожидании': 'pending',
            'В работе': 'in_progress',
            'Завершено': 'completed'
        };
       
        $('#new-status').val(statusMap[currentStatus] || 'pending');
        $('#statusModal').modal('show');
    });

    $('#save-status').on('click', function() {
        const taskId = $('#task-id').val();
        const newStatus = $('#new-status').val();
       
        $.ajax({
            url: '/tasks/update-status',
            method: 'POST',
            data: {
                task_id: taskId,
                status: newStatus
            },
            success: function(response) {
                if (response.success) {
                    // Обновляем бейдж на странице
                    $(`.status-select[data-task-id="${taskId}"]`).html(response.badge);
                    $('#statusModal').modal('hide');
                   
                    // Показываем уведомление
                    showToast('Статус обновлен', 'success');
                }
            },
            error: function(xhr) {
                showToast('Ошибка при обновлении статуса', 'danger');
            }
        });
    });

    // Удаление задачи
    $('.delete-task').on('click', function() {
        const taskId = $(this).data('id');
        const taskTitle = $(this).data('title');
       
        $('#task-title').text(taskTitle);
        $('#delete-form').attr('action', `/tasks/${taskId}/destroy`);
        $('#deleteModal').modal('show');
    });

    // Toast уведомления
    function showToast(message, type = 'info') {
        const toastId = 'toast-' + Date.now();
        const toastHtml = `
            <div id="${toastId}" class="toast align-items-center text-white bg-${type} border-0" role="alert">
                <div class="d-flex">
                    <div class="toast-body">
                        ${message}
                    </div>
                    <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
                </div>
            </div>
        `;
       
        $('#toast-container').append(toastHtml);
        const toast = new bootstrap.Toast(document.getElementById(toastId));
        toast.show();
       
        // Удаляем toast после скрытия
        document.getElementById(toastId).addEventListener('hidden.bs.toast', function() {
            this.remove();
        });
    }

    // Создаем контейнер для toast, если его нет
    if ($('#toast-container').length === 0) {
        $('body').append('<div id="toast-container" class="toast-container position-fixed bottom-0 end-0 p-3"></div>');
    }

    // Автоматическое скрытие alert через 5 секунд
    setTimeout(function() {
        $('.alert:not(.alert-permanent)').alert('close');
    }, 5000);
});

Стили CSS​

CSS:
/* public/css/style.css */
body {
    background-color: #f8f9fa;
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

.navbar-brand {
    font-weight: 600;
}

.card {
    border: none;
    box-shadow: 0 2px 4px rgba(0,0,0,.1);
    margin-bottom: 20px;
}

.card-header {
    background-color: rgba(0,0,0,.03);
    border-bottom: 1px solid rgba(0,0,0,.125);
}

.table th {
    border-top: none;
    font-weight: 600;
    color: #495057;
}

.table-hover tbody tr:hover {
    background-color: rgba(0,123,255,.05);
}

.status-select {
    cursor: pointer;
    transition: opacity 0.2s;
}

.status-select:hover {
    opacity: 0.8;
}

.badge {
    font-weight: 500;
    padding: 5px 10px;
}

.badge.bg-secondary {
    background-color: #6c757d !important;
}

.badge.bg-warning {
    background-color: #ffc107 !important;
    color: #212529;
}

.badge.bg-success {
    background-color: #28a745 !important;
}

.badge.bg-info {
    background-color: #17a2b8 !important;
}

.badge.bg-danger {
    background-color: #dc3545 !important;
}

.badge.bg-primary {
    background-color: #007bff !important;
}

.form-check-label .badge {
    cursor: pointer;
    opacity: 0.7;
    transition: opacity 0.2s;
}

.form-check-input:checked + .form-check-label .badge {
    opacity: 1;
    transform: scale(1.05);
}

.btn-group-sm > .btn {
    padding: 0.25rem 0.5rem;
    font-size: 0.875rem;
}

/* Анимации */
.fade-in {
    animation: fadeIn 0.3s;
}

@keyframes fadeIn {
    from { opacity: 0; }
    to { opacity: 1; }
}

/* Адаптивность */
@media (max-width: 768px) {
    .table-responsive {
        font-size: 0.9rem;
    }
   
    .btn-group {
        flex-wrap: wrap;
    }
   
    .btn-group-sm > .btn {
        margin-bottom: 2px;
    }
}

7. Роутер и точка входа​

Роутер​

PHP:
<?php
// app/Core/Router.php
namespace App\Core;

class Router
{
    private $routes = [];
    private $notFoundCallback;

    public function add($method, $path, $callback)
    {
        $this->routes[] = [
            'method' => strtoupper($method),
            'path' => $path,
            'callback' => $callback
        ];
    }

    public function get($path, $callback)
    {
        $this->add('GET', $path, $callback);
    }

    public function post($path, $callback)
    {
        $this->add('POST', $path, $callback);
    }

    public function put($path, $callback)
    {
        $this->add('PUT', $path, $callback);
    }

    public function delete($path, $callback)
    {
        $this->add('DELETE', $path, $callback);
    }

    public function notFound($callback)
    {
        $this->notFoundCallback = $callback;
    }

    public function dispatch()
    {
        $requestMethod = $_SERVER['REQUEST_METHOD'];
        $requestPath = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
       
        // Убираем базовый путь, если приложение не в корне
        $basePath = dirname($_SERVER['SCRIPT_NAME']);
        if ($basePath != '/') {
            $requestPath = substr($requestPath, strlen($basePath));
        }
       
        $requestPath = rtrim($requestPath, '/') ?: '/';
       
        foreach ($this->routes as $route) {
            if ($route['method'] !== $requestMethod) {
                continue;
            }
           
            $pattern = $this->convertToRegex($route['path']);
           
            if (preg_match($pattern, $requestPath, $matches)) {
                array_shift($matches); // Убираем полное совпадение
               
                // Преобразуем числовые параметры
                $matches = array_map(function($value) {
                    return is_numeric($value) ? (int) $value : $value;
                }, $matches);
               
                // Вызываем callback
                if (is_callable($route['callback'])) {
                    return call_user_func_array($route['callback'], $matches);
                } elseif (is_string($route['callback'])) {
                    return $this->callController($route['callback'], $matches);
                }
            }
        }
       
        // 404 Not Found
        if ($this->notFoundCallback) {
            call_user_func($this->notFoundCallback);
        } else {
            http_response_code(404);
            echo "404 Not Found";
        }
    }

    private function convertToRegex($path)
    {
        $pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '(?P<$1>[^/]+)', $path);
        return '#^' . $pattern . '$#';
    }

    private function callController($callback, $params)
    {
        list($controller, $method) = explode('@', $callback);
        $controllerClass = "App\\Controllers\\{$controller}";
       
        if (!class_exists($controllerClass)) {
            throw new \Exception("Controller {$controllerClass} not found");
        }
       
        $controllerInstance = new $controllerClass();
       
        if (!method_exists($controllerInstance, $method)) {
            throw new \Exception("Method {$method} not found in {$controllerClass}");
        }
       
        return call_user_func_array([$controllerInstance, $method], $params);
    }
}

Точка входа (index.php)​

PHP:
<?php
// public/index.php
require_once __DIR__ . '/../vendor/autoload.php';

use App\Core\Router;
use App\Controllers\TaskController;
use App\Controllers\AuthController;

// Включение отладки (только для разработки)
ini_set('display_errors', 1);
error_reporting(E_ALL);

session_start();

$router = new Router();

// Маршруты аутентификации
$router->get('/login', [AuthController::class, 'loginForm']);
$router->post('/login', [AuthController::class, 'login']);
$router->get('/register', [AuthController::class, 'registerForm']);
$router->post('/register', [AuthController::class, 'register']);
$router->get('/logout', [AuthController::class, 'logout']);

// Маршруты задач
$router->get('/', [TaskController::class, 'index']);
$router->get('/tasks', [TaskController::class, 'index']);
$router->get('/tasks/create', [TaskController::class, 'create']);
$router->post('/tasks/store', [TaskController::class, 'store']);
$router->get('/tasks/{id}', [TaskController::class, 'show']);
$router->get('/tasks/{id}/edit', [TaskController::class, 'edit']);
$router->post('/tasks/{id}/update', [TaskController::class, 'update']);
$router->post('/tasks/{id}/destroy', [TaskController::class, 'destroy']);

// AJAX маршруты
$router->post('/tasks/update-status', [TaskController::class, 'updateStatus']);
$router->get('/tasks/search', [TaskController::class, 'search']);

// 404 страница
$router->notFound(function() {
    http_response_code(404);
    echo "404 - Страница не найдена";
});

$router->dispatch();

Файл .htaccess для Apache​

Код:
# public/.htaccess
RewriteEngine On

# Перенаправление всех запросов на index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]

# Защита от доступа к скрытым файлам
<FilesMatch "^\.">
    Order allow,deny
    Deny from all
</FilesMatch>

# Кэширование статических файлов
<FilesMatch "\.(css|js|jpg|jpeg|png|gif|ico)$">
    ExpiresActive On
    ExpiresDefault "access plus 1 month"
</FilesMatch>

composer.json для автозагрузки​

Код:
{
    "name": "yourname/todo-app",
    "description": "To-Do List Application",
    "type": "project",
    "require": {
        "php": ">=7.4"
    },
    "autoload": {
        "psr-4": {
            "App\\": "app/"
        }
    },
    "authors": [
        {
            "name": "Your Name",
            "email": "your.email@example.com"
        }
    ],
    "minimum-stability": "stable"
}

Заключение​

Мы создали полноценную систему управления задачами на PHP, используя современные подходы:

  • ООП для структурирования кода
  • MVC для разделения ответственности
  • PDO для безопасной работы с БД
  • AJAX для улучшения UX
  • Bootstrap для адаптивного дизайна
Этот проект может служить основой для более сложных приложений и является отличным учебным примером для изучения PHP разработки.