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
Controllersnamespace - 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 emptyemail— Must be a valid email addressstring— Must be a string valuemin:N— Must be at least N characters longconfirmed— Must match a corresponding_confirmationfield (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.