Controllers
Controllers handle HTTP requests and return responses. They organize your application logic and keep route files clean.
Creating Controllers
Controllers live in app/Controllers/
and should extend Glueful\Controllers\BaseController
.
<?php
namespace App\Controllers;
use Glueful\Controllers\BaseController;
use Glueful\Http\Response;
class UserController extends BaseController
{
public function index()
{
$users = $this->db->table('users')->get();
return Response::success($users);
}
}
Basic Structure
A typical controller (using the BaseController convenience response helpers like $this->success()
and $this->created()
):
<?php
namespace App\Controllers;
use Glueful\Controllers\BaseController;
use Glueful\Http\Response;
class TaskController extends BaseController
{
// List all tasks
public function index()
{
$tasks = $this->db->table('tasks')->get();
return $this->success($tasks);
}
// Get single task
public function show(string $uuid)
{
$task = $this->db->table('tasks')
->where('uuid', $uuid)
->first();
if (!$task) {
return $this->notFound('Task not found');
}
return $this->success($task);
}
// Create task
public function store()
{
$data = $this->getRequestData();
// Basic validation (returns Response on failure or null if ok)
if ($error = $this->validateRequest($data, [
'title' => 'required|max:255',
'description' => 'max:1000'
])) {
return $error; // 422 validation error
}
$task = $this->db->table('tasks')->insert([
'uuid' => Utils::generateNanoID(),
'title' => $data['title'],
'description' => $data['description'] ?? null,
]);
return $this->created($task);
}
// Update task
public function update(string $uuid)
{
$data = $this->getRequestData();
$this->db->table('tasks')
->where('uuid', $uuid)
->update($data);
return $this->success(null, 'Task updated');
}
// Delete task
public function destroy(string $uuid)
{
$this->db->table('tasks')
->where('uuid', $uuid)
->delete();
return Response::noContent();
}
}
Dependency Injection
Inject services via constructor or method parameters:
<?php
namespace App\Controllers;
use App\Services\UserService;
use Glueful\Controllers\BaseController;
use Glueful\Http\Response;
use Psr\Log\LoggerInterface;
class UserController extends BaseController
{
public function __construct(
private UserService $users,
private LoggerInterface $logger
) {}
public function index()
{
$this->logger->info('Fetching users');
$users = $this->users->getAll();
return $this->success($users);
}
// Method injection
public function show(string $uuid, UserService $users)
{
$user = $this->db->table('users')->where('uuid', $uuid)->first();
return $this->success($user);
}
}
Built-in Properties
Base controller provides helpful properties & helpers:
class MyController extends BaseController
{
public function example()
{
// Database connection
$this->db->table('users')->get();
// Current request
$data = $this->getRequestData();
$email = $data['email'] ?? null; // or: $this->request->request->get('email')
// Cache
$this->cache->get('key');
// Logger
$this->logger->info('message');
}
}
ResourceController (Built‑in Generic CRUD)
Glueful ships with a powerful Glueful\Controllers\ResourceController
that provides generic, permission‑aware CRUD for any database table. It is intentionally conservative by default: most security features are opt‑in via protected boolean flags you can override or configure. Use it when you want to expose standard create/read/update/delete endpoints quickly without writing a bespoke controller.
Default Route Semantics
You typically mount it once and let route parameters determine the table. (Exact route wiring may vary depending on your routing layer — below is a conceptual example):
GET /resource/{resource} -> get() // list (paginated)
GET /resource/{resource}/{uuid} -> getSingle() // fetch one
POST /resource/{resource} -> post() // create
PUT /resource/{resource}/{uuid} -> put() // update
DELETE /resource/{resource}/{uuid} -> delete() // delete
Parameter names used internally: resource
for the table, uuid
for the row identifier (the repository layer resolves underlying primary key logic).
High‑Level Flow Per Operation
- Resolve table from route param
- (Optional) Apply table access control (
applyTableAccessControl
) - Enforce permission: tries table‑scoped permission first (e.g.
resource.posts.read
) then falls back to generic (resource.read
/resource.create
/resource.update
/resource.delete
) - Apply rate limiting per (table, action)
- For reads: parse pagination & filtering, call repository (cached)
- For writes: enforce low‑risk behavior, hash password field if present, invalidate cache
- Apply optional ownership and field‑level permission filtering
- Return standardized response via
Response
helpers
Security & Feature Toggles
These protected flags (all defined in ResourceController
) can be overridden in a subclass or influenced by configuration (see loadSecurityConfiguration()
):
protected bool $enableTableAccessControl = false; // Restrict which tables are exposed
protected bool $enableFieldPermissions = false; // Hide or filter sensitive fields
protected bool $enableBulkOperations = false; // (Reserved) enable secure bulk ops
protected bool $enableQueryRestrictions = false; // Sanitize/whitelist query params
protected bool $enableOwnershipValidation = true; // Enforce ownership on certain tables
Override the corresponding hook methods to implement behavior:
protected function applyTableAccessControl(string $table): void {}
protected function applyFieldPermissions($data, string $table, string $operation) { return $data; }
protected function applyQueryRestrictions(array $queryParams, string $table): array { return $queryParams; }
protected function applyOwnershipValidation(string $table, string $uuid, array $record): void {}
Ownership validation (enabled by default) currently checks tables like profiles
and blobs
for user‐scoped fields and escalates to admin permissions if mismatched.
Pagination & Filtering
List requests (get
) accept conventional query params:
Param | Meaning | Default |
---|---|---|
page | Page number (>=1) | 1 |
per_page | Page size (capped at 100) | 25 |
sort | Column to sort by | id |
order | asc or desc | desc |
fields | Comma list of columns to select / * | * (all) |
others | Treated as equality filters | — |
Filtering: any additional query parameter not in the reserved list (page, per_page, sort, order, fields
) becomes an equality condition passed to the repository. If you enable query restrictions you can intercept & sanitize these in applyQueryRestrictions
before conditions are built.
Caching Strategy
Read paths use cacheByPermission("resource:{table}:...", ...)
to create per‑permission scoped cache entries. After create/update/delete, invalidateTableCache($table, $uuid?)
clears related table and item level tags (repository:{table}
, resource:{table}
, resource:{table}:{uuid}
). This keeps stale data minimal without over‑invalidating.
Rate Limiting
Every action calls $this->rateLimitResource($table, $action, $limit, $windowSeconds)
with conservative defaults:
Action | Example Limit (per window) |
---|---|
read (list / single) | 100 / 60s |
create | 50 / 60s |
update | 30 / 60s |
delete | 10 / 60s |
Tweak by overriding method or subclassing and applying different limits.
Password Hashing Convenience
If incoming create/update payload includes a password
key it is automatically hashed using PasswordHasher
before persistence.
Example: Extending with Field-Level Permissions
use Glueful\Controllers\ResourceController;
class SecureResourceController extends ResourceController
{
protected bool $enableFieldPermissions = true;
protected bool $enableTableAccessControl = true;
protected function applyTableAccessControl(string $table): void
{
$allowed = ['posts', 'profiles'];
if (!in_array($table, $allowed, true)) {
$this->requirePermission('admin.resource.access');
}
}
protected function applyFieldPermissions($data, string $table, string $operation)
{
if ($table === 'profiles') {
// Hide internal columns
$filter = static function(array $row) {
unset($row['password_hash'], $row['secret_token']);
return $row;
};
if (isset($data['data']) && is_array($data['data'])) {
// Paginated shape: ['data' => [...], 'total' => ...]
$data['data'] = array_map($filter, $data['data']);
return $data;
}
if (is_array($data) && array_is_list($data)) {
return array_map($filter, $data);
}
if (is_array($data)) {
return $filter($data);
}
}
return $data;
}
}
When to Use vs. Custom Controller
Because the framework already registers dynamic resource routes in framework/routes/resource.php
, you can adopt ResourceController
immediately—no manual route wiring required. Decide per table whether to rely on the generic layer or create a bespoke controller.
Use ResourceController
when:
- You need fast CRUD exposure while still enforcing permissions & rate limits
- Table shape maps cleanly to what the API should return
- You benefit from automatic ownership validation & caching without customization
- You want consistent permission naming (
resource.{table}.{action}
)
Prefer a custom (hand‑written) controller when:
- You need multi‑table joins, aggregations, or transactional workflows
- Validation rules depend on context beyond simple field constraints
- You must orchestrate side‑effects (emails, events, external APIs) tightly per action
- Response payloads diverge significantly from base table rows (embedding, projections, domain DTOs)
- You want to version or stage breaking changes without affecting other tables sharing the generic endpoint
Hybrid strategy (common in larger apps):
- Start with
ResourceController
for rapid iteration - Promote specific tables to dedicated controllers as complexity or specialization grows
- Keep the generic route mounted for the rest
To “promote” a table: add explicit routes (e.g. /posts/...
) pointing to PostController
and optionally restrict or deny that table inside ResourceController::applyTableAccessControl
so the generic path no longer serves it.
Route Registration & Customization
The generic CRUD routes are already declared in framework/routes/resource.php
and include inline OpenAPI–style annotations plus middleware (auth
, rate limits per verb). You normally do not need to re‑declare them.
Customization approaches:
- Override behavior via subclass: Bind
ResourceController::class
to your subclass in the container (e.g. in a service provider) to change defaults globally. - Per‑table hardening: Override
applyTableAccessControl
to whitelist tables or escalate permissions for sensitive ones. - Disable generic for a specific table: Add logic in
applyTableAccessControl
to throw/require a special permission, then supply bespoke routes in your app’sroutes/
folder. - Adjust limits: Wrap or subclass and change calls to
rateLimitResource
(or provide config consumed by your override methods). - Bulk operations: Enable via config key
resource.security.bulk_operations
to expose the extra/bulk
endpoints already conditionally registered.
This keeps the default file as the canonical definition while allowing layered extensibility.
Request Handling
Getting Input
public function store()
{
// All input (JSON or form)
$all = $this->getRequestData();
// Specific field
$email = $all['email'] ?? null; // or: $this->request->request->get('email')
// With default (request bag)
$name = $this->request->request->get('name', 'Guest');
// Check if field exists
if ($this->request->request->has('email')) {
//...
}
}
Route Parameters
// Route: /users/{uuid}/posts/{postUuid}
public function show(string $uuid, string $postUuid)
{
$user = $this->db->table('users')->where('uuid', $uuid)->first();
$post = $this->db->table('posts')->where('uuid', $postUuid)->first();
return Response::success(['user' => $user, 'post' => $post]);
}
Query Parameters
public function index()
{
$page = (int) $this->request->query->get('page', 1);
$limit = (int) $this->request->query->get('limit', 20);
$users = $this->db->table('users')
->limit($limit)
->offset(($page - 1) * $limit)
->get();
return Response::success($users);
}
Validation
Validate input before processing:
public function store()
{
$data = $this->getRequestData();
if ($error = $this->validateRequest($data, [
'email' => 'required|email',
'name' => 'required|max:100',
])) {
return $error; // 422
}
$user = $this->db->table('users')->insert([
'uuid' => Utils::generateNanoID(),
'email' => $data['email'],
'name' => $data['name'],
]);
return $this->created($user);
}
Returns 422
response automatically if validation fails.
Common Patterns
RESTful CRUD
class PostController extends BaseController
{
public function index()
{
// GET /posts - List all
$posts = $this->db->table('posts')->get();
return $this->success($posts);
}
public function store()
{
// POST /posts - Create
$data = $this->getRequestData();
$post = $this->db->table('posts')->insert([
'uuid' => Utils::generateNanoID(),
'title' => $data['title'] ?? 'Untitled',
'body' => $data['body'] ?? null,
]);
return $this->created($post);
}
public function show(string $uuid)
{
// GET /posts/{uuid} - Show one
$post = $this->db->table('posts')->where('uuid', $uuid)->first();
return $post ? $this->success($post) : $this->notFound('Post not found');
}
public function update(string $uuid)
{
// PUT /posts/{uuid} - Update
$data = $this->getRequestData();
$updated = $this->db->table('posts')->where('uuid', $uuid)->update($data);
return $updated ? $this->success(null, 'Updated') : $this->notFound('Post not found');
}
public function destroy(string $uuid)
{
// DELETE /posts/{uuid} - Delete
$deleted = $this->db->table('posts')->where('uuid', $uuid)->delete();
return $deleted ? Response::noContent() : $this->notFound('Post not found');
}
}
Error Handling
public function show(string $id)
{
$user = $this->db->table('users')
->where('uuid', $id)
->first();
if (!$user) {
return $this->notFound('User not found');
}
return $this->success($user);
}
Pagination
public function index()
{
$page = (int) $this->request->query->get('page', 1);
$perPage = (int) $this->request->query->get('per_page', 20);
$users = $this->db->table('users')
->limit($perPage)
->offset(($page - 1) * $perPage)
->get();
$total = $this->db->table('users')->count();
return Response::paginated($users, $total, $page, $perPage);
}
Organization Tips
Keep Controllers Thin
Extract business logic to services:
// ❌ Fat controller
class OrderController extends BaseController
{
public function store()
{
$data = $this->getRequestData();
// Validate
// Calculate totals
// Check inventory
// Process payment
// Send emails
// Update stock
// Create order
return Response::created($order);
}
}
// ✅ Thin controller with service
class OrderController extends BaseController
{
public function __construct(
private OrderService $orders
) {}
public function store()
{
$data = $this->getRequestData();
$order = $this->orders->create($data);
return Response::created($order);
}
}
Use Sub-Controllers
Break large controllers into focused ones:
// Instead of one massive UserController
UserController
UserProfileController
UserSettingsController
UserNotificationsController
Next Steps
- Requests & Responses - Work with HTTP data
- Validation - Validate input
- Database - Query data