Контроллеры и маршрутизация
Контроллеры — это классы, которые обрабатывают HTTP-запросы (когда пользователь открывает страницу или отправляет форму). Каждый метод контроллера отвечает за конкретный URL.
Где размещать контроллеры
Контроллеры можно класть в три места (система найдёт их автоматически):
Http/Controllers/— основное место (рекомендуется)Controllers/— альтернативноеSubmodules/*/Controllers/— для подмодулей
При вызове bootstrapModule() в провайдере все контроллеры регистрируются автоматически. Ничего дополнительно делать не нужно.
Создание контроллера
Базовый пример
Создайте файл Http/Controllers/ArticleController.php:
<?php
namespace Flute\Modules\Blog\Http\Controllers;
use Flute\Core\Router\Annotations\Get;
use Flute\Core\Router\Annotations\Post;
use Flute\Core\Router\Annotations\Middleware;
use Flute\Core\Support\BaseController;
use Flute\Modules\Blog\database\Entities\Article;
/**
* Контроллер для работы со статьями блога.
*
* Атрибут #[Middleware('web')] применяется ко ВСЕМ методам контроллера.
* Он добавляет защиту CSRF и другие проверки.
*/
#[Middleware('web')]
class ArticleController extends BaseController
{
/**
* Список всех статей.
*
* #[Get('/blog')] означает:
* - Это GET-запрос (открытие страницы в браузере)
* - URL: /blog
* - name: 'blog.index' — имя маршрута для генерации ссылок
*/
#[Get('/blog', name: 'blog.index')]
public function index()
{
// Получаем все статьи из базы данных
// rep() — это сокращение от repository (репозиторий)
$articles = rep(Article::class)->findAll();
// Возвращаем HTML-страницу
// 'blog::pages.index' означает: модуль blog, папка pages, файл index.blade.php
return response()->view('blog::pages.index', [
'articles' => $articles
]);
}
/**
* Просмотр одной статьи.
*
* {id} — это параметр из URL. Например, /blog/5 → $id = 5
* where: ['id' => '[0-9]+'] — ограничение: id должен быть числом
*/
#[Get('/blog/{id}', name: 'blog.show', where: ['id' => '[0-9]+'])]
public function show(int $id)
{
$article = rep(Article::class)->findByPK($id);
// Если статья не найдена — показываем ошибку 404
if (!$article) {
return $this->error(__('blog.article_not_found'), 404);
}
return response()->view('blog::pages.show', [
'article' => $article
]);
}
/**
* Создание новой статьи.
*
* #[Post] — это POST-запрос (отправка формы)
* #[Middleware(['csrf'])] — дополнительная защита от CSRF-атак
*/
#[Post('/blog', name: 'blog.store')]
#[Middleware(['csrf'])]
public function store()
{
// Валидация данных из формы
$validated = $this->validate(request()->all(), [
'title' => 'required|string|min-str-len:3|max-str-len:255',
'content' => 'required|string|min-str-len:10'
]);
// Если валидация не прошла — возвращаем пользователя назад с ошибками
if ($validated !== true) {
return redirect()->back()->withErrors($this->errors());
}
// Создаём новую статью
$article = new Article();
$article->title = request()->get('title');
$article->content = request()->get('content');
$article->author_id = user()->id; // ID текущего пользователя
$article->created_at = new \DateTime();
// Сохраняем в базу данных
transaction($article)->run();
// Показываем сообщение об успехе и перенаправляем
$this->flash(__('blog.article_created'), 'success');
return redirect(route('blog.show', ['id' => $article->id]));
}
}Атрибуты маршрутизации
Атрибуты (начинаются с #[) говорят системе, какой URL ведёт к какому методу.
Доступные атрибуты
| Атрибут | HTTP-метод | Когда использовать |
|---|---|---|
#[Get] | GET | Показ страницы, получение данных |
#[Post] | POST | Отправка форм, создание данных |
#[Put] | PUT | Обновление существующих данных |
#[Delete] | DELETE | Удаление данных |
#[Route] | Любой | Когда нужно несколько методов |
#[Middleware] | — | Добавление проверок (авторизация, права) |
Параметры атрибутов
#[Get('/путь/{параметр}', name: 'имя.маршрута', where: ['параметр' => 'регулярка'])]- Первый аргумент — URL-путь
- name — уникальное имя для генерации ссылок через
route('имя') - where — ограничения для параметров (регулярные выражения)
- defaults — значения по умолчанию для необязательных параметров
Параметры в URL
Обязательный параметр
Параметр в {фигурных скобках} обязателен — без него URL не сработает:
#[Get('/users/{id}')]
public function show(int $id)
{
// /users/123 → $id = 123
// /users/ → ошибка 404
}Необязательный параметр
Добавьте ? к имени параметра и укажите значение по умолчанию:
#[Get('/articles/{category?}', defaults: ['category' => 'all'])]
public function index(string $category = 'all')
{
// /articles → $category = 'all'
// /articles/tech → $category = 'tech'
}Ограничения параметров
Параметр where позволяет указать, какие значения допустимы:
// Только числа
#[Get('/posts/{id}', where: ['id' => '[0-9]+'])]
// Только буквы и дефисы (для slug)
#[Get('/posts/{slug}', where: ['slug' => '[a-z0-9-]+'])]
// Несколько ограничений
#[Get('/archive/{year}/{month}', where: [
'year' => '[0-9]{4}', // 4 цифры: 2024
'month' => '[0-9]{2}' // 2 цифры: 01-12
])]
public function archive(int $year, int $month)
{
// /archive/2024/03 → $year = 2024, $month = 3
}Работа с запросами
Получение данных из запроса
public function search()
{
// GET-параметры (?page=2&search=php)
$page = request()->get('page', 1); // 1 — значение по умолчанию
$search = request()->get('search'); // null если не передано
// Все GET-параметры как массив
$allQuery = request()->query->all();
// POST-данные (из формы)
$title = request()->request->get('title');
// Загруженные файлы
$image = request()->files->get('image');
// Заголовки запроса
$userAgent = request()->headers->get('User-Agent');
// Параметр из URL (например, {id})
$id = request()->getAttribute('id');
}Проверка типа запроса
public function handle()
{
// Это POST-запрос?
if (request()->isMethod('POST')) {
// Обрабатываем форму
}
// Запрос ожидает JSON? (AJAX или API)
if (request()->expectsJson()) {
return $this->json(['success' => true]);
}
// Обычный запрос — возвращаем HTML
return response()->view('page');
}Валидация данных
Перед сохранением всегда проверяйте данные от пользователя:
public function store()
{
$validated = $this->validate(request()->all(), [
// Обязательное поле, строка, от 3 до 255 символов
'title' => 'required|string|min-str-len:3|max-str-len:255',
// Обязательное поле, минимум 10 символов
'content' => 'required|string|min-str-len:10',
// Должна быть запись с таким id в таблице categories
'category_id' => 'required|integer|exists:categories,id',
// Необязательный массив тегов
'tags' => 'array',
'tags.*' => 'string|max-str-len:50', // каждый тег — строка до 50 символов
// Булево значение (true/false)
'published' => 'boolean',
// Дата, которая должна быть в будущем
'publish_date' => 'nullable|date|after:now',
// Изображение до 2 МБ
'image' => 'nullable|image|max:2048'
]);
// $validated будет true если всё ок, иначе — объект с ошибками
if ($validated !== true) {
// Для AJAX автоматически вернётся JSON с ошибками
if (request()->expectsJson()) {
return $this->json($this->errors()->getErrors(), 422);
}
// Для обычных запросов — редирект назад с ошибками
return redirect()->back()->withErrors($this->errors());
}
// Данные прошли валидацию, можно сохранять
}Методы BaseController
Наследуя от BaseController, вы получаете полезные методы:
Ответы
// JSON-ответ (для API и AJAX)
return $this->json(['data' => $articles]);
return $this->json(['error' => 'Not found'], 404);
// Успешный ответ
return $this->success('Данные сохранены');
return $this->success('Создано', 201);
// Ответ с ошибкой
return $this->error('Статья не найдена', 404);
return $this->error('Нет доступа', 403);Уведомления пользователю
// Flash-сообщение (показывается на следующей странице)
$this->flash('Статья успешно создана!', 'success');
$this->flash('Ошибка при сохранении', 'error');
// Toast-уведомление (всплывающее)
$this->toast('Данные сохранены', 'success');
$this->toast('Что-то пошло не так', 'error');Безопасность
// Проверка CSRF-токена вручную
if (!$this->isCsrfValid()) {
return $this->error('Недействительный токен', 403);
}
// Ограничение количества запросов (защита от спама)
// 5 запросов в минуту на действие 'create_article'
$this->throttle('create_article', 5, 60);Генерация URL
По имени маршрута
Всегда используйте имена маршрутов вместо хардкода URL:
// ✅ Правильно — используем имя маршрута
$url = route('blog.show', ['id' => 123]);
// Результат: /blog/123
// ❌ Неправильно — хардкод URL
$url = '/blog/' . $id;Редиректы
// Редирект на URL
return redirect('/blog');
// Редирект по имени маршрута
return redirect(route('blog.index'));
// Редирект назад (на предыдущую страницу)
return redirect()->back();
// Редирект с flash-сообщением
return redirect(route('blog.index'))->with('success', 'Статья создана');Файлы маршрутов
Иногда удобнее описать маршруты в отдельном файле вместо атрибутов.
Файл routes.php НЕ загружается автоматически. Добавьте в провайдер:
$this->loadRoutesFrom('routes.php');Пример routes.php
<?php
use Flute\Modules\Blog\Http\Controllers\ArticleController;
use Flute\Modules\Blog\Http\Controllers\CommentController;
// Простые маршруты
router()->get('/blog', [ArticleController::class, 'index'])->name('blog.index');
router()->get('/blog/{id}', [ArticleController::class, 'show'])->name('blog.show');
router()->post('/blog', [ArticleController::class, 'store'])->name('blog.store');
// Группа маршрутов с общим префиксом и middleware
router()->group([
'prefix' => '/api/blog', // Все URL начинаются с /api/blog
'middleware' => ['api', 'auth'] // Требуется авторизация
], function () {
router()->get('/', [ArticleController::class, 'apiIndex']);
router()->get('/{id}', [ArticleController::class, 'apiShow']);
router()->post('/', [ArticleController::class, 'apiStore']);
router()->put('/{id}', [ArticleController::class, 'apiUpdate']);
router()->delete('/{id}', [ArticleController::class, 'apiDestroy']);
});
// Админские маршруты
router()->group([
'prefix' => '/admin/blog',
'middleware' => ['auth', 'can:manage_blog'] // Нужно право manage_blog
], function () {
router()->get('/', [AdminController::class, 'index'])->name('admin.blog.index');
router()->get('/create', [AdminController::class, 'create'])->name('admin.blog.create');
});Дополнительные методы
// Страница без контроллера (просто шаблон)
router()->view('/about', 'pages::about');
// Редирект без контроллера
router()->redirect('/old-url', '/new-url', 301);
// Один URL для нескольких HTTP-методов
router()->match(['GET', 'POST'], '/contact', [ContactController::class, 'handle']);
// Любой HTTP-метод
router()->any('/webhook', [WebhookController::class, 'handle']);Примеры контроллеров
API-контроллер
Контроллер для работы с мобильным приложением или внешними сервисами:
<?php
namespace Flute\Modules\Blog\Http\Controllers\Api;
use Flute\Core\Router\Annotations\Get;
use Flute\Core\Router\Annotations\Post;
use Flute\Core\Router\Annotations\Middleware;
use Flute\Core\Support\BaseController;
/**
* API для работы со статьями.
* Все ответы в формате JSON.
*/
#[Middleware(['api'])]
class ArticleApiController extends BaseController
{
/**
* Список статей с пагинацией.
* GET /api/articles?page=1&per_page=20
*/
#[Get('/api/articles', name: 'api.articles.index')]
public function index()
{
$page = (int) request()->get('page', 1);
$perPage = min((int) request()->get('per_page', 20), 100); // Максимум 100
$articles = rep(Article::class)
->select()
->where('published', true)
->orderBy('created_at', 'DESC')
->paginate($perPage, $page);
return $this->json([
'data' => $articles->items(),
'meta' => [
'current_page' => $articles->currentPage(),
'per_page' => $articles->perPage(),
'total' => $articles->total(),
'last_page' => $articles->lastPage(),
]
]);
}
/**
* Создание статьи через API.
* Требуется авторизация.
*/
#[Post('/api/articles', name: 'api.articles.store')]
#[Middleware(['auth'])]
public function store()
{
$validated = $this->validate(request()->all(), [
'title' => 'required|string|min-str-len:3|max-str-len:255',
'content' => 'required|string|min-str-len:10',
]);
if ($validated !== true) {
return $this->json([
'message' => 'Ошибка валидации',
'errors' => $this->errors()->getErrors()
], 422);
}
$article = new Article();
$article->title = request()->get('title');
$article->content = request()->get('content');
$article->author_id = user()->id;
$article->created_at = new \DateTime();
transaction($article)->run();
return $this->json([
'message' => 'Статья создана',
'data' => $article
], 201);
}
}Админ-контроллер
Контроллер для административной части:
<?php
namespace Flute\Modules\Blog\Http\Controllers\Admin;
use Flute\Core\Router\Annotations\Get;
use Flute\Core\Router\Annotations\Post;
use Flute\Core\Router\Annotations\Middleware;
use Flute\Core\Support\BaseController;
/**
* Управление статьями в админке.
* Доступ только для пользователей с правом manage_blog.
*/
#[Middleware(['auth', 'can:manage_blog'])]
class ArticleAdminController extends BaseController
{
#[Get('/admin/blog', name: 'admin.blog.index')]
public function index()
{
$articles = rep(Article::class)
->select()
->load('author') // Загружаем связанного автора
->orderBy('created_at', 'DESC')
->fetchAll();
return response()->view('blog::admin.index', [
'articles' => $articles
]);
}
#[Get('/admin/blog/create', name: 'admin.blog.create')]
public function create()
{
$categories = rep(Category::class)->findAll();
return response()->view('blog::admin.create', [
'categories' => $categories
]);
}
#[Post('/admin/blog', name: 'admin.blog.store')]
#[Middleware(['csrf'])]
public function store()
{
$validated = $this->validate(request()->all(), [
'title' => 'required|string|min-str-len:3|max-str-len:255',
'content' => 'required|string|min-str-len:10',
'category_id' => 'required|exists:categories,id',
]);
if ($validated !== true) {
return redirect()->back()
->withErrors($this->errors())
->withInput(); // Сохраняем введённые данные
}
$article = new Article();
$article->title = request()->get('title');
$article->content = request()->get('content');
$article->category_id = request()->get('category_id');
$article->author_id = user()->id;
transaction($article)->run();
$this->flash('Статья успешно создана!', 'success');
return redirect(route('admin.blog.index'));
}
}Обработка ошибок
Проверка существования записи
public function show(int $id)
{
$article = rep(Article::class)->findByPK($id);
if (!$article) {
// Для API — JSON с ошибкой
if (request()->expectsJson()) {
return $this->json(['error' => 'Статья не найдена'], 404);
}
// Для браузера — страница ошибки
return $this->error('Статья не найдена', 404);
}
return response()->view('blog::show', compact('article'));
}Обработка исключений
public function riskyAction()
{
try {
// Потенциально опасный код
$result = $this->externalApi->call();
return $this->json(['data' => $result]);
} catch (\Exception $e) {
// Логируем ошибку для разработчиков
logs('blog')->error('Ошибка API', [
'message' => $e->getMessage(),
'user_id' => user()->id,
]);
// Возвращаем понятное сообщение пользователю
return $this->error('Сервис временно недоступен', 503);
}
}Кастомные исключения
Создайте свои исключения для типичных ошибок:
<?php
namespace Flute\Modules\Blog\Exceptions;
class ArticleNotFoundException extends \Exception
{
public function __construct(int $id)
{
parent::__construct("Статья #{$id} не найдена");
}
}// Использование
public function show(int $id)
{
$article = rep(Article::class)->findByPK($id);
if (!$article) {
throw new ArticleNotFoundException($id);
}
return response()->view('blog::show', compact('article'));
}