Controllers

Last updated: 04/17/2026 · Written by Agent0

Controllers

Controllers are PHP classes that handle incoming requests. Each public method on a controller corresponds to a route action — it receives any URL parameters, does its work (fetching data, processing a form, etc.), and returns a rendered view or redirect.

All controllers live in app/Controllers/ and extend BaseController.


Creating a Controller

The fastest way is with the Stack CLI:

php stack create:controller Articles

This creates app/Controllers/ArticlesController.php with a basic index() method ready to fill in. Or scaffold the full CRUD set at once with:

php stack create:resource articles

See the Stack CLI doc for everything create:resource generates.


Basic Structure

<?php

namespace Controllers;

use App\Support\Query;

class ArticlesController extends BaseController
{
    public function index()
    {
        $articles = Query::table('articles')
            ->orderBy('created_at', 'DESC')
            ->get();

        return $this->render('app.articles.index', [
            'title'    => 'Articles',
            'articles' => $articles,
        ], 'layouts.app');
    }
}

Every controller:

  • Uses the Controllers namespace
  • Extends BaseController
  • Calls $this->render() to return a view

Rendering Views

$this->render() takes three arguments:

$this->render(string $view, array $data, string $layout);
  • $view — Dot-notation path to the view file inside resources/views/
  • $data — Associative array of variables to pass to the view
  • $layout — The layout to wrap the view in. Use 'layouts.app' for authenticated pages (sidebar + top nav) or 'layouts.public' for public-facing pages
// Authenticated page (sidebar layout)
return $this->render('app.articles.index', [
    'title'    => 'Articles',
    'articles' => $articles,
], 'layouts.app');

// Public page (no sidebar)
return $this->render('home.index', [
    'title' => 'Welcome',
], 'layouts.public');

URL Parameters

Dynamic route parameters are passed directly as method arguments, in the order they appear in the route URI.

// Route: /articles/{id}
public function show($id)
{
    $article = Query::table('articles')->find((int)$id);

    if (!$article) {
        abort(404);
    }

    return $this->render('app.articles.show', [
        'title'   => $article->title,
        'article' => $article,
    ], 'layouts.app');
}

Handling Forms

Form data comes in through $_POST. A typical save() method handles both create and update by checking for a hidden id field in the form.

public function save()
{
    $id = !empty($_POST['id']) ? (int)$_POST['id'] : null;

    $data = [
        'title'      => trim($_POST['title'] ?? ''),
        'content'    => $_POST['content'] ?? '',
        'created_by' => auth('id'),
    ];

    if ($id) {
        Query::table('articles')->where('id', $id)->update($data);
        flash('success', 'Article updated.');
    } else {
        $data['slug'] = generate_slug($_POST['title'], 'articles');
        Query::table('articles')->insert($data);
        flash('success', 'Article created.');
    }

    redirect('/articles');
}

Form Validation

Use the built-in Validator class to validate $_POST data before writing to the database. If validation fails, flash the errors and the old input back to the form.

use Core\Validator;

public function save()
{
    $validator = new Validator($_POST);

    $valid = $validator->validate([
        'title'   => 'required|string|min:3',
        'content' => 'required',
        'email'   => 'required|email',
    ]);

    if (!$valid) {
        flash('errors', $validator->errors());
        $_SESSION['_old'] = $_POST;
        redirect_back();
    }

    // Validation passed — safe to write
    Query::table('articles')->insert([
        'title'   => trim($_POST['title']),
        'content' => $_POST['content'],
    ]);

    flash('success', 'Article created.');
    redirect('/articles');
}

Available validation rules

  • required — Field must not be empty
  • email — Must be a valid email address
  • string — Must be a string value
  • min:N — Must be at least N characters long
  • confirmed — Must match a corresponding _confirmation field (used for password confirmation)
  • unique:table,column — Value must not already exist in the database

Redirecting

Use the redirect() helper to send the user to another URL. Always call it after a form submission — never render a view directly after a POST.

redirect('/articles');             // go to a URL
redirect_back();                   // go back to the previous page
redirect_back('/articles');        // go back, with a fallback URL

Flash Messages

Flash messages survive exactly one redirect and are then cleared. They're displayed automatically in the base layout as toast notifications.

flash('success', 'Article saved successfully.');
flash('error', 'Something went wrong.');
flash('info', 'Please check your email.');

redirect('/articles');

Aborting with an Error

Use abort() to stop execution and render the matching error page.

$article = Query::table('articles')->find((int)$id);

if (!$article) {
    abort(404); // renders resources/views/errors/404.php
}

// 403 for permission failures
if (!can('edit', 'article')) {
    abort(403);
}

A Full Example Controller

<?php

namespace Controllers;

use App\Support\Query;
use Core\Validator;

class ArticlesController extends BaseController
{
    public function index()
    {
        $articles = Query::table('articles')
            ->orderBy('created_at', 'DESC')
            ->paginate(15);

        return $this->render('app.articles.index', [
            'title'    => 'Articles',
            'articles' => $articles,
        ], 'layouts.app');
    }

    public function form($id = null)
    {
        $article = $id ? Query::table('articles')->find((int)$id) : null;

        return $this->render('app.articles.form', [
            'title'   => $id ? 'Edit Article' : 'New Article',
            'article' => $article,
        ], 'layouts.app');
    }

    public function save()
    {
        $id        = !empty($_POST['id']) ? (int)$_POST['id'] : null;
        $validator = new Validator($_POST);

        if (!$validator->validate(['title' => 'required|min:3', 'content' => 'required'])) {
            flash('errors', $validator->errors());
            $_SESSION['_old'] = $_POST;
            redirect_back();
        }

        $data = [
            'title'   => trim($_POST['title']),
            'content' => $_POST['content'],
        ];

        if ($id) {
            Query::table('articles')->where('id', $id)->update($data);
            flash('success', 'Article updated.');
        } else {
            $data['slug'] = generate_slug($_POST['title'], 'articles');
            Query::table('articles')->insert($data);
            flash('success', 'Article created.');
        }

        redirect('/articles');
    }

    public function delete($id)
    {
        Query::table('articles')->where('id', (int)$id)->delete();
        flash('success', 'Article deleted.');
        redirect('/articles');
    }
}

For database usage inside controllers, see the Query doc. For protecting controller actions at the route level, see Auth & Middleware.

Was this helpful?