Skip to content
Back to skills

Php Codeigniter Patterns

ASecurity

Guides CodeIgniter 4 application architecture — Controllers, Models (Query Builder, not an ORM), Entities, the Validation library, Filters (CI4's middleware equivalent), and Services. Use when building a CI4 controller or route, working with a CI4 Model or Entity, wiring a Filter to a route group, or adding a custom Service.

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 16, 2026
ai-agentsgophptestinggitapidatabasesecurity

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 3 files and shows the line behind each finding

Scanned October 16, 2026

npx -y skills add shennawardana23/skillme --skill php-codeigniter-patterns --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Php Codeigniter Patterns?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Php Codeigniter Patterns
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/shennawardana23-php-codeigniter-patterns/badge)](https://www.skillsdirectory.com/skills/shennawardana23-php-codeigniter-patterns)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: php-codeigniter-patterns
description: Guides CodeIgniter 4 application architecture — Controllers, Models (Query Builder, not an ORM), Entities, the Validation library, Filters (CI4's middleware equivalent), and Services. Use when building a CI4 controller or route, working with a CI4 Model or Entity, wiring a Filter to a route group, or adding a custom Service.
license: Apache-2.0
metadata:
  version: "0.1.0"
---

# PHP CodeIgniter Patterns

This is the architecture layer for CodeIgniter 4 (CI4) applications: how a
request flows from Router → Filters → Controller → Model → Entity, and where
each concern belongs. It is not a testing guide (`php-codeigniter-tdd`), not
a security checklist (`php-codeigniter-security`), and not a pre-deploy
verification pipeline (`php-codeigniter-verification`) — apply those
alongside this one.

**The single biggest CI4-vs-Laravel difference**: CI4's `Model` class is
**not an ORM**. It has no relationship methods (`hasMany`, `belongsTo`), no
eager loading, and no Active Record-style object graph. A `Model` is a thin
wrapper that exposes Query Builder plus a handful of lifecycle conveniences
(mass-assignment guarding, timestamps, soft deletes, validation). Per the
[CI4 Model user guide](https://codeigniter4.github.io/userguide/models/model.html):
"Once you get the Query Builder instance, you can call methods of the Query
Builder. However, since Query Builder is not a Model, you cannot call
methods of the Model." Related rows are fetched with an explicit second
query or a manual `JOIN` in the Query Builder call — there is no `->with()`
to reach for, and no N+1-via-eager-loading gotcha to guard against, because
eager loading doesn't exist. The N+1 risk in CI4 is the mirror image: a
developer coming from Laravel/Eloquent instinctively expects a relationship
method to exist and is surprised when it doesn't.

## Layering

```
app/Controllers/  → routing + request/response shaping only
app/Filters/       → CI4's middleware equivalent (auth, rate limiting, CORS)
app/Models/        → Query Builder wrapper: allowedFields, validation, casts
app/Entities/      → typed row objects (optional; set via Model::$returnType)
app/Config/        → Services.php, Filters.php, Validation.php, Routes.php
```

## Models: Query Builder, not an ORM

```php
namespace App\Models;

use CodeIgniter\Model;

class ReservationModel extends Model
{
    protected $table            = 'reservations';
    protected $primaryKey       = 'id';
    protected $returnType       = \App\Entities\Reservation::class;
    protected $useTimestamps    = true;
    protected $useSoftDeletes   = true;

    // Fields the model will accept from insert()/update()/save() arrays.
    // Never include $primaryKey here.
    protected $allowedFields = ['guest_id', 'hotel_id', 'check_in', 'check_out', 'status'];

    protected $validationRules = [
        'guest_id'  => 'required|integer',
        'check_in'  => 'required|valid_date',
        'check_out' => 'required|valid_date',
    ];
}
```

`$allowedFields` is CI4's mass-assignment guard: "Any field names other than
these will be discarded" ([Model user
guide](https://codeigniter4.github.io/userguide/models/model.html)) — it is
the exact same class of protection as Laravel's `$fillable`, just under a
different name. `$validationRules` on the Model runs automatically just
before `insert()`/`update()`/`save()` persist data, so a Model that skips
`$validationRules` and relies solely on controller-level validation loses
protection for any other code path (a console command, a queued job) that
calls the Model directly.

Since CI4 v4.3.0, `update()` raises a `DatabaseException` if the generated
`UPDATE` statement would have no `WHERE` clause — a deliberate guard against
a Model call that accidentally updates every row in the table.

Reaching into the underlying Query Builder for anything beyond simple CRUD:

```php
$reservations = $this->reservationModel
    ->where('hotel_id', $hotelId)
    ->where('status', 'confirmed')
    ->orderBy('check_in', 'ASC')
    ->findAll();

// Manual join — there is no relationship method to call instead
$rows = $this->reservationModel
    ->select('reservations.*, guests.name AS guest_name')
    ->join('guests', 'guests.id = reservations.guest_id')
    ->where('reservations.hotel_id', $hotelId)
    ->findAll();
```

## Entities: typed row objects

Setting `Model::$returnType` to an Entity class makes `find()`/`findAll()`
return typed objects instead of arrays or `stdClass`:

```php
namespace App\Entities;

use CodeIgniter\Entity\Entity;

class Reservation extends Entity
{
    protected $casts = [
        'check_in'  => 'datetime',
        'check_out' => 'datetime',
        'metadata'  => 'json-array',
    ];

    // snake_case column `check_in` maps to CamelCase-prefixed accessor
    public function setCheckIn(string $value): static
    {
        $this->attributes['check_in'] = date('Y-m-d H:i:s', strtotime($value));
        return $this;
    }
}
```

Entities expose magic `__get()`/`__set()` that look for a `getX()`/`setX()`
method (snake_case column → PascalCase-prefixed method) before falling back
to raw attribute access — this is where per-field business logic (hashing,
normalizing, casting) belongs, not scattered across controllers.

## Validation: controller-level, and the `getVar()` gotcha

```php
public function update(int $id)
{
    if (! $this->validateData($this->request->getPost(), [
        'check_in'  => 'required|valid_date',
        'check_out' => 'required|valid_date|greater_than_field[check_in]',
    ])) {
        return $this->response->setJSON(['errors' => $this->validator->getErrors()])
            ->setStatusCode(422);
    }
    // ...
}
```

Reusable rule groups belong in `app/Config/Validation.php` as named public
properties, run via `$validation->run($data, 'groupName')` — this is the CI4
equivalent of a Laravel `FormRequest` class:

```php
// app/Config/Validation.php
public array $reservationCreate = [
    'guest_id'  => 'required|integer|is_not_unique[guests.id]',
    'check_in'  => 'required|valid_date',
    'check_out' => 'required|valid_date|greater_than_field[check_in]',
];
```

## Filters: CI4's middleware equivalent

```php
namespace App\Filters;

use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;

class HotelScopeFilter implements FilterInterface
{
    public function before(RequestInterface $request, $arguments = null)
    {
        if (! session()->get('hotel_id')) {
            return redirect()->to('/login');
        }
    }

    public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
    {
        // Filters can only modify/return the Response here, not stop execution.
    }
}
```

```php
// app/Config/Filters.php
public array $aliases = [
    'hotelscope' => \App\Filters\HotelScopeFilter::class,
];

public array $globals = [
    'before' => ['csrf'],
    'after'  => [],
];
```

```php
// app/Config/Routes.php — attach a filter to a route group
$routes->group('hotels/(:num)', ['filter' => 'hotelscope'], static function ($routes) {
    $routes->get('reservations', 'ReservationController::index');
});
```

The filter value must match an alias defined in `$aliases` — a typo here
fails silently at the routing layer (the route still matches, the filter
just never runs) rather than throwing, so a missing filter on a route group
is easy to miss in review.

## Services: the service locator

Custom cross-cutting collaborators are registered in
`app/Config/Services.php` (extends `CodeIgniter\Config\BaseService`) and
resolved through the `service()` helper or `\Config\Services::name()` — both
are equivalent. Services default to a **shared** instance (same object
returned on every call in one request); pass `false` for a fresh instance:

```php
// app/Config/Services.php
public static function pricingEngine(bool $getShared = true): PricingEngine
{
    if ($getShared) {
        return static::getSharedInstance('pricingEngine');
    }
    return new PricingEngine(config('Pricing'));
}
```

```php
$engine = service('pricingEngine');          // shared instance
$fresh  = service('pricingEngine', false);   // new instance
```

## Gotchas

- **`$this->validate()` reads `$_GET`, `$_POST`, and `$_COOKIE`, in that
  order** (via `$request->getVar()`) — a request with a cookie named the
  same as a validated field can have its cookie value validated instead of
  the POST body, and the CI4 docs explicitly recommend `validateData()`
  (validates an explicit array — typically `$this->request->getPost()`)
  for POST-only validation instead of the ambiguous `validate()` shorthand.
- **`$allowedFields` must never include the primary key** — the Model user
  guide states this explicitly; including it lets a client-supplied `id` in
  a `create`-style form silently retarget an `update()` call or corrupt
  autoincrement behavior on `insert()`.
- **There is no eager loading, because there is no ORM.** A developer
  porting Laravel muscle memory will look for `->with(['guest'])` and not
  find it — the correct CI4 pattern is either a second `findAll()` keyed by
  the foreign IDs, or a `JOIN` in the Query Builder call. Neither is wrong;
  picking one inconsistently across a codebase is a maintenance cost, not a
  functional bug.
- **A `Filters.php` alias typo fails silently.** The route still resolves
  and the controller still runs — the filter that was supposed to gate it
  simply never executes. Confirm a new filter alias with a test request,
  not just a code read.
- **`update()` without a `WHERE`-implying primary key throws since v4.3.0**
  — code written against an older CI4 version that relied on this being a
  silent full-table update will break on upgrade; treat the exception as
  the framework catching a bug, not a regression to work around.

## Real-world grounding

CI4's decision to keep `Model` as a Query Builder wrapper rather than
building a full ORM (Eloquent- or Doctrine-style) is a documented, explicit
design choice by the framework — the user guide's opening line on the topic
is functionally "Query Builder is not a Model, and a Model is not Query
Builder," drawing the boundary on purpose rather than as an omission. Teams
migrating a CI3 (pre-4.0) codebase, which had no formal Model class at all
and typically hand-rolled Query Builder calls directly in controllers, tend
to under-use `$allowedFields`/`$validationRules` because the old codebase
never had anywhere to put them — this is the most common defect this skill
catches in a CI3→CI4 migration review.

## Verification

- [ ] Every Model accepting request-derived data declares `$allowedFields`
      (never leaves it empty while still calling `insert()`/`update()`/`save()`
      with raw request arrays)
- [ ] `$allowedFields` never includes the primary key column
- [ ] Model-level `$validationRules` exist for any Model reachable from a
      non-HTTP path (console command, queued job), not just controller checks
- [ ] Controllers use `validateData()` (or an explicit rule group via
      `Config\Validation`) rather than bare `validate()` when the intent is
      "validate POST data only"
- [ ] Every route group requiring auth/tenant-scoping has a `filter` key
      whose value matches a real alias in `Config\Filters::$aliases`
- [ ] Cross-cutting collaborators are registered in `Config\Services.php`
      rather than `new`'d directly inside controllers

See `references/models-entities-and-filters.md` for Entity `$datamap`,
custom casts, filter execution order across `$required`/`$globals`/
`$methods`/`$filters`, and Query Builder join patterns.

Files in this skill

  • SKILL.md11.5 KB
  • evals/evals.json2.1 KB
  • references/models-entities-and-filters.md3.1 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.