Controllers and Routing
Controllers are classes that handle HTTP requests (when a user opens a page or submits a form). Each controller method corresponds to a specific URL.
Where to Place Controllers
Controllers can be placed in three locations (the system will find them automatically):
Http/Controllers/— primary location (recommended)Controllers/— alternativeSubmodules/*/Controllers/— for submodules
When calling bootstrapModule() in the provider, all controllers are registered automatically. No additional steps are required.
Creating a Controller
Basic Example
Create the file 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;
/**
* Controller for blog articles.
*
* The #[Middleware('web')] attribute applies to ALL methods in the controller.
* It adds CSRF protection and other checks.
*/
#[Middleware('web')]
class ArticleController extends BaseController
{
/**
* List of all articles.
*
* #[Get('/blog')] means:
* - It's a GET request (opening a page in browser)
* - URL: /blog
* - name: 'blog.index' — route name for generating links
*/
#[Get('/blog', name: 'blog.index')]
public function index()
{
// Get all articles from database
// rep() is short for repository
$articles = rep(Article::class)->findAll();
// Return HTML page
// 'blog::pages.index' means: module blog, folder pages, file index.blade.php
return response()->view('blog::pages.index', [
'articles' => $articles
]);
}
/**
* View a single article.
*
* {id} is a URL parameter. E.g., /blog/5 → $id = 5
* where: ['id' => '[0-9]+'] — constraint: id must be a number
*/
#[Get('/blog/{id}', name: 'blog.show', where: ['id' => '[0-9]+'])]
public function show(int $id)
{
$article = rep(Article::class)->findByPK($id);
// If article not found — show 404 error
if (!$article) {
return $this->error(__('blog.article_not_found'), 404);
}
return response()->view('blog::pages.show', [
'article' => $article
]);
}
/**
* Create a new article.
*
* #[Post] is a POST request (form submission)
* #[Middleware(['csrf'])] — additional CSRF protection
*/
#[Post('/blog', name: 'blog.store')]
#[Middleware(['csrf'])]
public function store()
{
// Validate form data
$validated = $this->validate(request()->all(), [
'title' => 'required|string|min-str-len:3|max-str-len:255',
'content' => 'required|string|min-str-len:10'
]);
// If validation fails — redirect back with errors
if ($validated !== true) {
return redirect()->back()->withErrors($this->errors());
}
// Create new article
$article = new Article();
$article->title = request()->get('title');
$article->content = request()->get('content');
$article->author_id = user()->id; // Current user ID
$article->created_at = new \DateTime();
// Save to database
transaction($article)->run();
// Show success message and redirect
$this->flash(__('blog.article_created'), 'success');
return redirect(route('blog.show', ['id' => $article->id]));
}
}Routing Attributes
Attributes (starting with #[) tell the system which URL leads to which method.
Available Attributes
| Attribute | HTTP Method | When to Use |
|---|---|---|
#[Get] | GET | Showing page, getting data |
#[Post] | POST | Submitting forms, creating data |
#[Put] | PUT | Updating existing data |
#[Delete] | DELETE | Deleting data |
#[Route] | Any | When multiple methods are needed |
#[Middleware] | — | Adding checks (auth, permissions) |
Attribute Parameters
#[Get('/path/{param}', name: 'route.name', where: ['param' => 'regex'])]- First argument — URL path
- name — unique name for generating links via
route('name') - where — constraints for parameters (regex)
- defaults — default values for optional parameters
URL Parameters
Mandatory Parameter
A parameter in {curly braces} is mandatory — URL won’t work without it:
#[Get('/users/{id}')]
public function show(int $id)
{
// /users/123 → $id = 123
// /users/ → 404 error
}Optional Parameter
Add ? to the parameter name and specify a default value:
#[Get('/articles/{category?}', defaults: ['category' => 'all'])]
public function index(string $category = 'all')
{
// /articles → $category = 'all'
// /articles/tech → $category = 'tech'
}Parameter Constraints
The where parameter allows specifying valid values:
// Only numbers
#[Get('/posts/{id}', where: ['id' => '[0-9]+'])]
// Only letters and hyphens (for slug)
#[Get('/posts/{slug}', where: ['slug' => '[a-z0-9-]+'])]
// Multiple constraints
#[Get('/archive/{year}/{month}', where: [
'year' => '[0-9]{4}', // 4 digits: 2024
'month' => '[0-9]{2}' // 2 digits: 01-12
])]
public function archive(int $year, int $month)
{
// /archive/2024/03 → $year = 2024, $month = 3
}Handling Requests
Getting Request Data
public function search()
{
// GET parameters (?page=2&search=php)
$page = request()->get('page', 1); // 1 — default value
$search = request()->get('search'); // null if not passed
// All GET parameters as array
$allQuery = request()->query->all();
// POST data (from form)
$title = request()->request->get('title');
// Uploaded files
$image = request()->files->get('image');
// Request headers
$userAgent = request()->headers->get('User-Agent');
// URL parameter (e.g., {id})
$id = request()->getAttribute('id');
}Checking Request Type
public function handle()
{
// Is it a POST request?
if (request()->isMethod('POST')) {
// Handle form
}
// Does request expect JSON? (AJAX or API)
if (request()->expectsJson()) {
return $this->json(['success' => true]);
}
// Regular request — return HTML
return response()->view('page');
}Data Validation
Always validate user input before saving:
public function store()
{
$validated = $this->validate(request()->all(), [
// Required field, string, 3 to 255 chars
'title' => 'required|string|min-str-len:3|max-str-len:255',
// Required field, min 10 chars
'content' => 'required|string|min-str-len:10',
// Must have a record with this id in categories table
'category_id' => 'required|integer|exists:categories,id',
// Optional array of tags
'tags' => 'array',
'tags.*' => 'string|max-str-len:50', // each tag is a string up to 50 chars
// Boolean value (true/false)
'published' => 'boolean',
// Date must be in the future
'publish_date' => 'nullable|date|after:now',
// Image up to 2 MB
'image' => 'nullable|image|max:2048'
]);
// $validated will be true if OK, otherwise — object with errors
if ($validated !== true) {
// For AJAX, JSON with errors is returned automatically
if (request()->expectsJson()) {
return $this->json($this->errors()->getErrors(), 422);
}
// For regular requests — redirect back with errors
return redirect()->back()->withErrors($this->errors());
}
// Data passed validation, can save
}BaseController Methods
Inheriting from BaseController gives you useful methods:
Responses
// JSON response (for API and AJAX)
return $this->json(['data' => $articles]);
return $this->json(['error' => 'Not found'], 404);
// Success response
return $this->success('Data saved');
return $this->success('Created', 201);
// Error response
return $this->error('Article not found', 404);
return $this->error('Access denied', 403);User Notifications
// Flash message (shown on next page)
$this->flash('Article created successfully!', 'success');
$this->flash('Error saving', 'error');
// Toast notification (popup)
$this->toast('Data saved', 'success');
$this->toast('Something went wrong', 'error');Security
// Manual CSRF token check
if (!$this->isCsrfValid()) {
return $this->error('Invalid token', 403);
}
// Request limiting (spam protection)
// 5 requests per minute for 'create_article' action
$this->throttle('create_article', 5, 60);URL Generation
By Route Name
Always use route names instead of hardcoding URLs:
// ✅ Correct — use route name
$url = route('blog.show', ['id' => 123]);
// Result: /blog/123
// ❌ Incorrect — hardcoded URL
$url = '/blog/' . $id;Redirects
// Redirect to URL
return redirect('/blog');
// Redirect by route name
return redirect(route('blog.index'));
// Redirect back (to previous page)
return redirect()->back();
// Redirect with flash message
return redirect(route('blog.index'))->with('success', 'Article created');Route Files
Sometimes it’s more convenient to describe routes in a separate file instead of attributes.
routes.php file is NOT loaded automatically. Add to provider:
$this->loadRoutesFrom('routes.php');Example routes.php
<?php
use Flute\Modules\Blog\Http\Controllers\ArticleController;
use Flute\Modules\Blog\Http\Controllers\CommentController;
// Simple routes
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');
// Route group with common prefix and middleware
router()->group([
'prefix' => '/api/blog', // All URLs start with /api/blog
'middleware' => ['api', 'auth'] // Authorization required
], 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']);
});
// Admin routes
router()->group([
'prefix' => '/admin/blog',
'middleware' => ['auth', 'can:manage_blog'] // Need manage_blog permission
], function () {
router()->get('/', [AdminController::class, 'index'])->name('admin.blog.index');
router()->get('/create', [AdminController::class, 'create'])->name('admin.blog.create');
});Additional Methods
// Page without controller (just template)
router()->view('/about', 'pages::about');
// Redirect without controller
router()->redirect('/old-url', '/new-url', 301);
// Single URL for multiple HTTP methods
router()->match(['GET', 'POST'], '/contact', [ContactController::class, 'handle']);
// Any HTTP method
router()->any('/webhook', [WebhookController::class, 'handle']);Controller Examples
API Controller
Controller for working with mobile app or external services:
<?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 for working with articles.
* All responses in JSON format.
*/
#[Middleware(['api'])]
class ArticleApiController extends BaseController
{
/**
* List articles with pagination.
* 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); // Max 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(),
]
]);
}
/**
* Create article via API.
* Requires authorization.
*/
#[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' => 'Validation error',
'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' => 'Article created',
'data' => $article
], 201);
}
}Admin Controller
Controller for the administrative part:
<?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 articles in admin panel.
* Access only for users with manage_blog permission.
*/
#[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') // Load related 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(); // Save input data
}
$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('Article created successfully!', 'success');
return redirect(route('admin.blog.index'));
}
}Error Handling
Checking Record Existence
public function show(int $id)
{
$article = rep(Article::class)->findByPK($id);
if (!$article) {
// For API — JSON with error
if (request()->expectsJson()) {
return $this->json(['error' => 'Article not found'], 404);
}
// For browser — error page
return $this->error('Article not found', 404);
}
return response()->view('blog::show', compact('article'));
}Exception Handling
public function riskyAction()
{
try {
// Potentially dangerous code
$result = $this->externalApi->call();
return $this->json(['data' => $result]);
} catch (\Exception $e) {
// Log error for developers
logs('blog')->error('API Error', [
'message' => $e->getMessage(),
'user_id' => user()->id,
]);
// Return user-friendly message
return $this->error('Service temporarily unavailable', 503);
}
}Custom Exceptions
Create your own exceptions for typical errors:
<?php
namespace Flute\Modules\Blog\Exceptions;
class ArticleNotFoundException extends \Exception
{
public function __construct(int $id)
{
parent::__construct("Article #{$id} not found");
}
}// Usage
public function show(int $id)
{
$article = rep(Article::class)->findByPK($id);
if (!$article) {
throw new ArticleNotFoundException($id);
}
return response()->view('blog::show', compact('article'));
}