Skip to Content
ModulesControllers & Routes

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):

  1. Http/Controllers/ — primary location (recommended)
  2. Controllers/ — alternative
  3. Submodules/*/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

AttributeHTTP MethodWhen to Use
#[Get]GETShowing page, getting data
#[Post]POSTSubmitting forms, creating data
#[Put]PUTUpdating existing data
#[Delete]DELETEDeleting data
#[Route]AnyWhen 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')); }