Essentials

Controllers

Organize request handling logic

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

  1. Resolve table from route param
  2. (Optional) Apply table access control (applyTableAccessControl)
  3. 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)
  4. Apply rate limiting per (table, action)
  5. For reads: parse pagination & filtering, call repository (cached)
  6. For writes: enforce low‑risk behavior, hash password field if present, invalidate cache
  7. Apply optional ownership and field‑level permission filtering
  8. 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:

ParamMeaningDefault
pagePage number (>=1)1
per_pagePage size (capped at 100)25
sortColumn to sort byid
orderasc or descdesc
fieldsComma list of columns to select / ** (all)
othersTreated 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:

ActionExample Limit (per window)
read (list / single)100 / 60s
create50 / 60s
update30 / 60s
delete10 / 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:

  1. Override behavior via subclass: Bind ResourceController::class to your subclass in the container (e.g. in a service provider) to change defaults globally.
  2. Per‑table hardening: Override applyTableAccessControl to whitelist tables or escalate permissions for sensitive ones.
  3. Disable generic for a specific table: Add logic in applyTableAccessControl to throw/require a special permission, then supply bespoke routes in your app’s routes/ folder.
  4. Adjust limits: Wrap or subclass and change calls to rateLimitResource (or provide config consumed by your override methods).
  5. 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