Skip to Content
Разработка модулейКонтроллеры и маршруты

Контроллеры и маршрутизация

Контроллеры — это классы, которые обрабатывают HTTP-запросы (когда пользователь открывает страницу или отправляет форму). Каждый метод контроллера отвечает за конкретный URL.

Где размещать контроллеры

Контроллеры можно класть в три места (система найдёт их автоматически):

  1. Http/Controllers/ — основное место (рекомендуется)
  2. Controllers/ — альтернативное
  3. 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')); }