Zero Reflection, Zero Regrets
Every PHP developer knows the pain. You’re deep in a template, staring at $data['user']['address']['city'], wondering if that key actually exists or if you’re about to trigger a notice that’ll haunt your logs forever.
DTOs solve this. But the cure has often been worse than the disease.
This post aims to:
array > ArrayObject > DTO performance loss
Modern PHP DTO libraries are clever. Too clever. They use runtime reflection to magically hydrate objects from arrays, infer types from docblocks, and validate on the fly. It’s beautiful—until you profile it.
Every. Single. Instantiation. Pays the reflection tax.
For a simple API endpoint returning 100 users? That’s 100 reflection calls. For a batch job processing 10,000 records? You’re burning CPU cycles on introspection instead of actual work.
And then there’s the IDE problem. Magic means your IDE is guessing. “Find Usages” becomes “Find Some Usages, Maybe.” PHPStan needs plugins. Autocomplete works… sometimes.
Here’s a radical idea: what if we did all that reflection once, at build time, and generated plain PHP classes?
Data Transfer Objects (DTOs) have become essential in modern PHP applications. They provide type safety, IDE autocomplete, and make your code more maintainable. But the PHP ecosystem has long debated how to implement them: runtime reflection or manual boilerplate?
php-collective/dto takes a third path: code generation. Define your DTOs once in configuration, generate optimized PHP classes, and enjoy the best of both worlds.
The PHP DTO landscape in 2026 looks like this:
These are excellent tools, but they share a common limitation: runtime reflection overhead. Every time you create a DTO, the library inspects class metadata, parses types, and builds the object dynamically.
What if we did all that work once, at build time?
The idea is not that radical after all. Similar implementations have existed for more than 15 years, way before modern PHP and the new syntax and features it brought along.
I have been using it for a bit more than 11 years now myself.
You decide on config as XML, YAML, NEON or PHP.
PHP using builders is the most powerful one, as it has full auto-complete/type-hinting:
return Schema::create()
->dto(Dto::create('User')->fields(
Field::int('id')->required(),
Field::string('email')->required(),
Field::dto('address', 'Address'),
))
->toArray();
Run the generator:
vendor/bin/dto generate
Get a real PHP class:
class UserDto extends AbstractDto
{
public function getId(): int { /* ... */ }
public function getEmail(): string { /* ... */ }
public function getAddress(): ?AddressDto { /* ... */ }
public function setEmail(string $email): static { /* ... */ }
// ...
}
No magic. No reflection. Just PHP.
The concept was first used almost 2 decades ago in e-commerce systems that had a high amount of modular packages and basically disallowed all manual array usage.
All had to be DTOs for maximum extendability and discoverability. The project could add fields per DTO as needed.
The XMLs of each module as well as project extensions were all merged together. XML makes this easy, and the generated DTOs are fully compatible with both core and project level.
I never needed the “merging” feature, but I did like how quickly you could generate them, and that it could always generate full DTOs with all syntactic sugar as per current “language standards”.
Personally I always liked the XML style, because with XSD modern IDEs have full autocomplete and validation on them. But in some cases PHP might be more flexible and powerful.
Choose what works for your team:
XML (with XSD validation):
<dto name="User">
<field name="id" type="int" required="true"/>
<field name="email" type="string" required="true"/>
<field name="roles" type="string[]" collection="true"/>
</dto>
Or use YAML or NEON for minimal syntax. Or stick to the PHP one above.
Mutable (default) – traditional setters:
$user = new UserDto();
$user->setName('John');
$user->setEmail('john@example.com');
Immutable – returns new instances:
$user = new UserDto(['name' => 'John']);
$updated = $user->withEmail('john@example.com');
// $user is unchanged, $updated has new email
Configure per-DTO:
Dto::immutable('Event')->fields(/* ... */);
APIs use snake_case. JavaScript wants camelCase. Forms send dashed-keys. Handle all of them:
// From snake_case database
$dto->fromArray($dbRow, false, UserDto::TYPE_UNDERSCORED);
// To camelCase for JavaScript
return $dto->toArray(); // default camelCase
// To snake_case for Python API
return $dto->toArray(UserDto::TYPE_UNDERSCORED);
<dto name="Order">
<field name="items" type="OrderItem[]" collection="true" singular="item"/>
</dto>
Generated methods:
$order->getItems(); // ArrayObject<OrderItemDto>
$order->addItem($itemDto); // Type-checked
$order->hasItems(); // Collection not empty
Associative collections work too:
$config->addSetting('theme', $settingDto);
$theme = $config->getSetting('theme');
Custom collection factories let you use Laravel Collections, Doctrine ArrayCollection, or CakePHP Collection (when generated with a non-\ArrayObject collection type):
Dto::setCollectionFactory(fn($items) => collect($items));
// Now all getters return Laravel collections
$order->getItems()->filter(...)->sum(...);
$company = new CompanyDto($data);
// Safe nested reading with default
$city = $company->read(['departments', 0, 'address', 'city'], 'Unknown');
// Deep cloning - nested objects are fully cloned
$clone = $company->clone();
$clone->getDepartments()[0]->setName('Changed');
// Original unchanged
Share types with your frontend:
vendor/bin/dto typescript --output=frontend/src/types/
Generates:
export interface UserDto {
id: number;
email: string;
name?: string;
roles: string[];
}
export interface OrderDto {
id: number;
customer: UserDto;
items: OrderItemDto[];
}
Options include multi-file output, readonly interfaces, and strict null handling.
Know exactly what was changed:
$dto = new UserDto();
$dto->setEmail('new@example.com');
$changes = $dto->touchedToArray();
// ['email' => 'new@example.com']
// Perfect for partial database updates
$repository->update($userId, $changes);
Every nullable field gets an OrFail variant:
$email = $dto->getEmail(); // string|null
$email = $dto->getEmailOrFail(); // string (throws if null)
Use after validation to avoid null checks:
$email = $dto->getEmailOrFail(); // PHPStan now knows it is not nullable
Enforce data integrity at creation:
<field name="id" type="int" required="true"/>
new UserDto(['name' => 'John']);
// InvalidArgumentException: Required fields missing: id
Beyond required fields, you can add common validation constraints:
Dto::create('User')->fields(
Field::string('name')->required()->minLength(2)->maxLength(100),
Field::string('email')->required()->pattern('/^[^@]+@[^@]+\.[^@]+$/'),
Field::int('age')->min(0)->max(150),
)
| Rule | Applies To | Description |
|---|---|---|
minLength |
string | Minimum string length |
maxLength |
string | Maximum string length |
min |
int, float | Minimum numeric value |
max |
int, float | Maximum numeric value |
pattern |
string | Regex pattern validation |
Validation runs on instantiation. Null fields skip validation — rules only apply when a value is present.
The validationRules() method extracts all rules as metadata, useful for bridging to framework validators:
$rules = $dto->validationRules();
// ['name' => ['required' => true, 'minLength' => 2, 'maxLength' => 100], ...]
<field name="status" type="\App\Enum\OrderStatus"/>
// From enum instance
$order->setStatus(OrderStatus::Pending);
// From backing value - auto-converted
$order = new OrderDto(['status' => 'confirmed']);
$order->getStatus(); // OrderStatus::Confirmed
<field name="price" type="\Money\Money"/>
<field name="createdAt" type="\DateTimeImmutable"/>
Custom factories for complex instantiation:
Field::class('date', \DateTimeImmutable::class)->factory('createFromFormat')
Apply callables to transform values during hydration or serialization:
Field::string('email')
->transformFrom('App\\Transform\\Email::normalize') // Before hydration
->transformTo('App\\Transform\\Email::mask') // After serialization
Useful for normalizing input (trimming, lowercasing) or masking output (hiding sensitive data). For collections, transforms apply to each element.
Share common fields:
Dto::create('BaseEntity')->fields(
Field::int('id')->required(),
Field::class('createdAt', \DateTimeImmutable::class),
)
Dto::create('User')->extends('BaseEntity')->fields(
Field::string('email')->required(),
)
// UserDto has id, createdAt, and email
Every generated DTO now gets shaped array types on toArray() and createFromArray():
// UserDto with fields: id (int, required), name (string), email (string, required)
/**
* @return array{id: int, name: string|null, email: string}
*/
public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array
$dto->toArray()['na suggests name
$dto->toArray()['naem'] shows error
['name' => $name] = $dto->toArray() infers $name as string|null
Complement your TypeScript types with JSON Schema for API documentation and contract testing:
vendor/bin/dto jsonschema --output=schemas/
Supports --single-file (with $defs references), --multi-file, --no-refs (inline nested objects), and --date-format options.
mapFrom() and mapTo() — read from email_address in input, write to emailAddr in output
string|int)
@return ArrayObject<int, ItemDto>)
getFullName() from firstName + lastName)
serialize()/unserialize()
--mapper) for SELECT NEW style constructors
class UserController
{
public function show(int $id): JsonResponse
{
$user = $this->repository->find($id);
$dto = UserDto::createFromArray($user->toArray());
// Snake case for JSON API
return new JsonResponse($dto->toArray(UserDto::TYPE_UNDERSCORED));
}
}
public function update(Request $request, int $id): Response
{
$dto = new UserDto();
$dto->fromArray($request->all(), false, UserDto::TYPE_UNDERSCORED);
// Only update fields that were actually submitted
$this->repository->update($id, $dto->touchedToArray());
return new Response('Updated');
}
$event = new OrderPlacedDto([
'eventId' => Uuid::uuid4()->toString(),
'aggregateId' => $orderId,
'occurredAt' => new DateTimeImmutable(),
'order' => $orderDto,
]);
// Create corrected version without mutating original
$corrected = $event->withVersion(2);
We ran comprehensive benchmarks comparing php-collective/dto against plain PHP, spatie/laravel-data, and cuyz/valinor. Test environment: PHP 8.4.17, 10,000 iterations per test.
Versions used: php-collective/dto dev-master (e4e1f9c), spatie/laravel-data 4.19.1, cuyz/valinor 2.3.2. A standalone comparison also includes spatie/data-transfer-object 3.9.1 and symfony/serializer 8.0.5.
| Library | Avg Time | Operations/sec | Relative |
|---|---|---|---|
| Plain PHP readonly DTO | 0.27 µs | 3.64M/s | 2.2x faster |
| php-collective/dto createFromArray() | 0.60 µs | 1.68M/s | baseline |
| spatie/laravel-data from() | 14.77 µs | 67.7K/s | 25x slower |
| cuyz/valinor | 15.78 µs | 63.4K/s | 26x slower |
Standalone benchmarks (using spatie/data-transfer-object instead of laravel-data, which requires a full Laravel app) show 52.8K/s and symfony/serializer 106K/s.
| Library | Avg Time | Operations/sec | Relative |
|---|---|---|---|
| Plain PHP nested DTOs | 1.75 µs | 571K/s | 1.8x faster |
| php-collective/dto | 3.10 µs | 322K/s | baseline |
| spatie/laravel-data | 48.83 µs | 20.5K/s | 16x slower |
| cuyz/valinor | 68.67 µs | 14.6K/s | 22x slower |
Standalone nested results: spatie/data-transfer-object 10.6K/s, symfony/serializer 13.6K/s.
The gap widens with complexity. Runtime libraries pay reflection costs for every nested object. Generated code doesn’t.
| Library | Avg Time | Operations/sec | Relative |
|---|---|---|---|
| Plain PHP toArray() | 0.68 µs | 1.48M/s | 1.8x faster |
| php-collective/dto | 1.20 µs | 832K/s | baseline |
| spatie/laravel-data | 26.95 µs | 37.1K/s | 22x slower |
| Approach | Avg Time | Operations/sec |
|---|---|---|
| Plain PHP property access | 0.11 µs | 9.48M/s |
| php-collective/dto getters | 0.20 µs | 4.91M/s |
| Plain array access | 0.15 µs | 6.77M/s |
Getter methods are nearly as fast as direct property access – the small overhead is negligible in real applications.
| Operation | Avg Time | Operations/sec |
|---|---|---|
| Mutable: setName() | 0.08 µs | 13.1M/s |
| Immutable: withName() | 0.12 µs | 8.34M/s |
Immutable operations are ~1.6x slower due to object cloning, but still extremely fast at 8.3 million operations per second.
| Approach | Avg Time | Operations/sec |
|---|---|---|
| Plain array -> JSON | 1.13 µs | 888K/s |
| Plain PHP DTO -> JSON | 2.07 µs | 484K/s |
| php-collective/dto -> JSON | 2.95 µs | 339K/s |
At 339K JSON documents per second, this is more than sufficient for any web application. A typical API handles 1K-10K requests/second.
Simple DTO Creation (ops/sec, higher is better):
┌──────────────────────────────────────────────────────────────────┐
│ Plain PHP ████████████████████████████████████ 3.64M/s │
│ php-collective ██████████████████ 1.68M/s │
│ laravel-data █ 67.7K/s │
│ valinor █ 63.4K/s │
└──────────────────────────────────────────────────────────────────┘
Complex Nested DTO (ops/sec, higher is better):
┌──────────────────────────────────────────────────────────────────┐
│ Plain PHP ██████████████████████████████████ 571K/s │
│ php-collective ███████████████████ 322K/s │
│ laravel-data ████ 20.5K/s │
│ valinor ███ 14.6K/s │
└──────────────────────────────────────────────────────────────────┘
toArrayFast() avoids per-field metadata lookups
Choose php-collective/dto when:
Consider alternatives when:
php-collective/dto brings the best of code generation to PHP DTOs:
| Aspect | php-collective/dto | Runtime Libraries |
|---|---|---|
| Performance | 25-26x faster | Baseline |
| IDE Support | Excellent | Good |
| Static Analysis | Native | Requires plugins |
| Code Review | Visible generated code | Magic/runtime |
| Build Step | Required | None |
The library is framework-agnostic, well-documented, and actively maintained.
For many apps the performance overhead of reflection might not be relevant.
After all, you might only have a few DTOs per template for simpler actions.
But in the case that you are handling a huge amount of DTOs, a less magic way could be a viable option. At least it will be more efficient than trying to nano-optimize on other parts of the application.
Adopting DTOs doesn’t have to be a big-bang rewrite. Here’s a practical, incremental path from raw arrays to fully typed DTOs — each step delivers value on its own.
This is where most legacy PHP projects start. Data flows as associative arrays, and every access is a leap of faith:
// Controller
public function view(int $id): Response
{
$user = $this->Users->get($id, contain: ['Addresses', 'Roles']);
$data = $user->toArray();
// Pass array to service
$summary = $this->buildSummary($data);
return $this->response->withJson($summary);
}
private function buildSummary(array $data): array
{
return [
'full_name' => $data['first_name'] . ' ' . $data['last_name'],
'city' => $data['address']['city'] ?? 'Unknown', // exists?
'role_count' => count($data['roles'] ?? []), // array?
];
}
Problems: no autocomplete, no type safety, no way to know the shape without reading the query. A typo like $data['adress'] silently returns null.
Start where it hurts most — the API response layer. Replace outgoing arrays with DTOs:
// Define the DTO config
Dto::create('UserSummary')->fields(
Field::string('fullName')->required(),
Field::string('city'),
Field::int('roleCount'),
);
vendor/bin/dto generate
// Controller — only the return type changes
public function view(int $id): Response
{
$user = $this->Users->get($id, contain: ['Addresses', 'Roles']);
$summary = new UserSummaryDto([
'fullName' => $user->first_name . ' ' . $user->last_name,
'city' => $user->address?->city,
'roleCount' => count($user->roles),
]);
return $this->response->withJson($summary->toArray());
}
The entity query stays the same. The service layer stays the same. But the API contract is now explicit, typed, and autocomplete-friendly. If someone removes city from the DTO config, the generator catches it.
Once boundaries are typed, push DTOs into service signatures:
// Before: what does this array contain? Who knows.
public function calculateShipping(array $order): float
// After: explicit contract
public function calculateShipping(OrderDto $order): float
{
$weight = $order->getItems()
->filter(fn(OrderItemDto $item) => $item->getWeight() > 0)
->sum(fn(OrderItemDto $item) => $item->getWeight());
return $this->rateCalculator->forWeight($weight, $order->getAddress());
}
Every caller now gets a compile-time check (via PHPStan) that they’re passing the right data. The method signature is the documentation.
Target the most common pattern — methods that return arrays of mixed data:
// Before
public function getStats(): array
{
return [
'total_users' => $this->Users->find()->count(),
'active_today' => $this->Users->find('activeToday')->count(),
'revenue' => $this->Orders->find()->sumOf('total'),
];
}
// Template: $stats['total_users'] — typo-prone, no autocomplete
// After
public function getStats(): DashboardStatsDto
{
return new DashboardStatsDto([
'totalUsers' => $this->Users->find()->count(),
'activeToday' => $this->Users->find('activeToday')->count(),
'revenue' => $this->Orders->find()->sumOf('total'),
]);
}
// Template: $stats->getTotalUsers() — autocomplete, type-checked
For CakePHP 5.3+, skip the entity entirely on read paths:
// Before: full entity hydration, then manual mapping
$users = $this->Users->find()
->select(['id', 'email', 'name', 'created'])
->contain(['Roles'])
->all()
->toArray();
// After: straight to DTO, no entity in between
$users = $this->Users->find()
->select(['id', 'email', 'name', 'created'])
->contain(['Roles'])
->projectAs(UserListDto::class)
->all()
->toArray();
The query result maps directly into UserListDto objects. No entity overhead, no intermediate array step.
Not everything needs a DTO. Prioritize based on pain:
| Priority | Where | Why |
|---|---|---|
| High | API responses | External contract, most likely to break silently |
| High | Service method params | Most frequent source of “what keys does this array have?” |
| Medium | Template variables | Autocomplete in templates reduces bugs |
| Medium | Queue/event payloads | Serialization boundaries need explicit shapes |
| Low | Internal helper returns | If only one caller exists, the overhead isn’t worth it |
| Skip | Simple key-value configs | Arrays are fine for ['timeout' => 30] |
UserDto::createFromArray($entity->toArray()) as a bridge during migration — no need to refactor the query layer first.
A live demo is available in the sandbox.
Especially check out the “projection” examples that map the DB content 1:1 into speaking DTOs.
The needed DTOs can be (re-)generated from the backend with a single click from the DB structure if needed.
Generated code is boring. Predictable. Fast.
Sometimes boring is exactly what you need.
php-collective/dto is available on Packagist. MIT licensed. PRs welcome.
Breaking Free from Google Maps: Modern Open-Source Alternatives for CakePHP.
Almost 20 years ago I started to work with GoogleMaps already.
Then slowly also Geocoding and other tooling was added. It all was grouped together into a useful and popular Geo plugin.
Nowadays there are many great alternatives, a lot of them open source or with free tiers, as well.
I recently had a surprise invoice from Google, where a small pet project suddenly had 50-80 EUR per month for static map rendering.
With open-source/free tiers pet projects would not necessarily create these issues, rather not display a map, with seems safer for the beginning. They have like 5000 displays per day, which should be enough to get around, and also not create issues with bots or other ways of over-using it for some reason.
Google Maps has been the go-to solution for web mapping for years, but the pricing model has become increasingly aggressive:
This isn’t sustainable for hobby projects, personal websites, or even small business applications where mapping is a minor feature rather than the core product.
The CakePHP Geo plugin has evolved to support a comprehensive set of alternatives. Instead of being locked into a single provider, you can now:
The new LeafletHelper brings Leaflet.js to CakePHP – a lightweight, open-source JavaScript library that powers maps on thousands of websites.
// In your controller or view
$this->loadHelper('Geo.Leaflet', ['autoScript' => true]);
// Create a map
$map = $this->Leaflet->map([
'zoom' => 13,
'lat' => 48.2082,
'lng' => 16.3738,
]);
echo $map;
// Add markers
$this->Leaflet->addMarker([
'lat' => 48.2082,
'lng' => 16.3738,
'title' => 'Vienna',
'content' => 'Welcome to <b>Vienna</b>!',
]);
$this->Leaflet->finalize();
One of the biggest advantages of Leaflet is tile provider independence. The plugin includes built-in presets for popular free providers:
// OpenStreetMap (default)
$this->Leaflet->useTilePreset(LeafletHelper::TILES_OSM);
// CartoDB Light - great for data visualization
$this->Leaflet->useTilePreset(LeafletHelper::TILES_CARTO_LIGHT);
// CartoDB Dark - perfect for dark mode UIs
$this->Leaflet->useTilePreset(LeafletHelper::TILES_CARTO_DARK);
Or use any custom tile provider:
echo $this->Leaflet->map([
'zoom' => 10,
'lat' => 48.2082,
'lng' => 16.3738,
'tileLayer' => [
'url' => 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
'options' => [
'attribution' => '© OpenStreetMap, © OpenTopoMap',
'maxZoom' => 17,
],
],
]);
| Provider | Free Tier | Best For |
|---|---|---|
| OpenStreetMap | Unlimited* | General purpose |
| CartoDB | 75k/month | Clean design, data viz |
| Stadia/Stamen | 200k/month | Artistic styles |
| OpenTopoMap | Unlimited* | Outdoor/hiking |
| Esri | Unlimited* | Professional maps |
| CyclOSM | Unlimited* | Cycling routes |
*Fair use policy applies
The LeafletHelper supports everything you’d expect from a modern mapping library:
// Auto-centering on markers
$map = $this->Leaflet->map(['autoCenter' => true]);
$this->Leaflet->addMarker(['lat' => 48.2, 'lng' => 16.3]);
$this->Leaflet->addMarker(['lat' => 47.0, 'lng' => 15.4]);
$this->Leaflet->finalize();
// Drawing shapes
$this->Leaflet->addPolyline(
['lat' => 48.2082, 'lng' => 16.3738],
['lat' => 47.0707, 'lng' => 15.4395],
['color' => '#ff0000', 'weight' => 5]
);
// Circles with radius
$this->Leaflet->addCircle([
'lat' => 48.2082,
'lng' => 16.3738,
'radius' => 5000, // meters
'fillOpacity' => 0.2,
]);
// GeoJSON support
$this->Leaflet->addGeoJson($geoJsonData);
// Marker clustering for large datasets
$this->Leaflet->enableClustering();
The new StaticMapHelper generates static map images from multiple providers – perfect for emails, PDFs, or pages where you don’t need interactivity.
| Provider | Free Tier | Sign-up |
|---|---|---|
| Geoapify | 3,000/day | geoapify.com |
| Mapbox | 50k/month | mapbox.com |
| Stadia | 200k/month | stadiamaps.com |
| Pay-as-you-go | cloud.google.com |
You can also create custom providers by extending the base provider classes if you need to integrate with other services.
$this->loadHelper('Geo.StaticMap');
// Basic static map
echo $this->StaticMap->image([
'lat' => 48.2082,
'lng' => 16.3738,
'zoom' => 12,
]);
// Switch providers easily
echo $this->StaticMap->image([
'provider' => StaticMapHelper::PROVIDER_GEOAPIFY,
'lat' => 48.2082,
'lng' => 16.3738,
'zoom' => 12,
'style' => 'osm-bright',
]);
// Add markers
echo $this->StaticMap->image([
'provider' => StaticMapHelper::PROVIDER_MAPBOX,
'zoom' => 12,
'markers' => [
['lat' => 48.2082, 'lng' => 16.3738, 'color' => 'red', 'label' => 'A'],
['lat' => 48.1951, 'lng' => 16.3715, 'color' => 'blue', 'label' => 'B'],
],
]);
Set up your preferred provider globally:
// config/app_local.php
'StaticMap' => [
'provider' => 'geoapify',
'size' => '400x300',
'geoapify' => [
'apiKey' => env('GEOAPIFY_API_KEY'),
'style' => 'osm-bright',
],
],
The Geocoder now supports multiple providers with automatic fallback:
// config/app_local.php
'Geocoder' => [
// Use Nominatim (free, OpenStreetMap-based) as default
'provider' => Geocoder::PROVIDER_NOMINATIM,
'nominatim' => [
'userAgent' => 'MyApp/1.0', // Required by OSM policy
],
],
Set up automatic failover between providers:
'Geocoder' => [
'providers' => [
Geocoder::PROVIDER_NOMINATIM, // Try free option first
Geocoder::PROVIDER_GEOAPIFY, // Fall back to Geoapify
Geocoder::PROVIDER_GOOGLE, // Google as last resort
],
'nominatim' => [
'userAgent' => 'MyApp/1.0',
],
'geoapify' => [
'apiKey' => env('GEOAPIFY_API_KEY'),
],
'google' => [
'apiKey' => env('GOOGLE_MAPS_API_KEY'),
],
],
The chain automatically handles rate limiting and server errors, trying the next provider when one fails.
| Provider | Free Tier | API Key | Notes |
|---|---|---|---|
| Nominatim | 1 req/sec | No | OSM-based, requires user-agent |
| Geoapify | 3,000/day | Yes (free) | Good accuracy |
| $200 credit/month | Yes | Best accuracy, expensive beyond credit |
For unit tests, use the NullProvider to avoid external API calls:
'Geocoder' => [
'provider' => Geocoder::PROVIDER_NULL,
],
This returns predictable mock data, making your tests fast and reliable.
Moving from Google-only to multi-provider is straightforward:
Before:
$this->loadHelper('Geo.GoogleMap');
echo $this->GoogleMap->map();
$this->GoogleMap->addMarker(['lat' => 48.2, 'lng' => 16.3]);
$this->GoogleMap->finalize();
After:
$this->loadHelper('Geo.Leaflet', ['autoScript' => true]);
echo $this->Leaflet->map();
$this->Leaflet->addMarker(['lat' => 48.2, 'lng' => 16.3]);
$this->Leaflet->finalize();
Before:
echo $this->GoogleMap->staticMap(['center' => '48.2,16.3', 'zoom' => 12]);
After:
$this->loadHelper('Geo.StaticMap');
echo $this->StaticMap->image([
'lat' => 48.2,
'lng' => 16.3,
]);
Before:
'Geocoder' => [
'apiKey' => env('GOOGLE_MAPS_API_KEY'),
],
After:
'Geocoder' => [
'provider' => Geocoder::PROVIDER_GEOAPIFY,
'geoapify' => [
'apiKey' => env('GEOAPIFY_API_KEY'),
],
],
When displaying many markers on a Leaflet map, clustering prevents visual clutter and improves performance:
$this->Leaflet->enableClustering();
// Add hundreds of markers
foreach ($locations as $location) {
$this->Leaflet->addMarker([
'lat' => $location->lat,
'lng' => $location->lng,
]);
}
Nearby markers are automatically grouped into clusters that expand when clicked or zoomed.
For applications with larger datasets, the plugin now supports spatial queries using native database spatial functions. Instead of calculating distances purely in PHP or with basic SQL, you can leverage spatial indexes for significant performance improvements.
$query = $this->Addresses->find('spatial', [
'lat' => 48.2082,
'lng' => 16.3738,
'distance' => 100, // km
]);
The spatial finder uses a two-stage approach:
ST_Within() leverages spatial indexes to quickly eliminate distant records
ST_Distance_Sphere() then calculates precise distances on the filtered result set
This works with MySQL 5.7+, MariaDB 10.4+, and PostGIS databases. For smaller datasets, the standard distance finder remains a simpler option.
To use spatial queries, you need a POINT column with a spatial index. Here’s an example migration:
public function up(): void {
// Add coordinates column as nullable first
$this->table('addresses')
->addColumn('coordinates', 'point', ['null' => true])
->update();
// Populate from existing lat/lng data
$this->execute("
UPDATE addresses
SET coordinates = ST_GeomFromText(CONCAT('POINT(', lng, ' ', lat, ')'))
");
// Make NOT NULL with SRID 0 (required for spatial index)
$this->execute("
ALTER TABLE addresses
MODIFY COLUMN coordinates POINT NOT NULL SRID 0
");
// Add spatial index
$this->execute('ALTER TABLE addresses ADD SPATIAL INDEX coordinates (coordinates)');
}
Note: SRID 0 (Cartesian coordinate system) is required for the spatial index to work properly with ST_Within().
When lat/lng values change, the coordinates POINT column must be updated. You can handle this in beforeSave():
public function beforeSave(EventInterface $event, EntityInterface $entity): void {
// Only for MySQL/MariaDB
if (!$this->getConnection()->getDriver() instanceof Mysql) {
return;
}
if ($entity->isDirty('lat') || $entity->isDirty('lng') || $entity->isNew()) {
$entity->set(
'coordinates',
$this->getConnection()->newQuery()
->func('ST_GeomFromText', [sprintf('POINT(%s %s)', $entity->lng, $entity->lat)])
);
}
}
Alternatively, you can use a database trigger to automatically sync the coordinates column whenever lat/lng are inserted or updated.
See all these features in action at the Sandbox:
You no longer need to be locked into Google’s pricing model. The CakePHP Geo plugin now provides:
All with minimal code changes from your existing Google-based implementation.
Give your projects the freedom they deserve. Check out the live demos to get started.
For details on the latest release with those new features, check 3.7.0 Release Notes.
If you are a nerd like me, you probably appreciate my recent 5.3 addition of a more concise default output of available commands.
It already reduced scrolling by a lot, compared to before (which is now -v).
But for prod/staging, I often open up an even smaller CLI terminal, so here I still have to scroll way too much.
For this there is a neat new super-concise output available via Setup plugin v3.17.0+.
Either add it based on debug mode (only for prod), or just globally:
'Setup' => [
'compactHelp' => true,
],
Example output:
bin/cake
No command provided. Choose one of the available commands.
Available Commands:
- asset_compress [build|clear]
- audit_stash [cleanup]
- cache [clear|clear_all|clear_group|list]
- cli_test
- completion
- counter_cache
- current_config [configure|display|phpinfo|validate]
- database_logs [cleanup|export|monitor|reset|show]
- db [init|reset|wipe]
- db_backup [create|restore]
- db_data [dates|enums|orphans]
- db_integrity [bools|constraints|ints|keys|nulls]
- healthcheck
- help
- i18n [dump_from_db|extract|extract_to_db|init|validate]
- inflect
- issues
- mail_check
- mailer
- main
- maintenance_mode [activate|deactivate|status|whitelist]
- migrations [dump|mark_migrated|migrate|rollback|status]
- page_cache [clear|status]
- plugin [list|load|loaded|unload]
- plugin assets [copy|remove|symlink]
- queue [add|info|job|run|worker]
- real_notification
- reset
- routes [check|generate]
- scheduler [run]
- schema_cache [build|clear]
- seeds [reset|run|status]
- server
- tiny_auth [add|sync]
- user [create|update]
- user_notification
- version
Use Djot templating with the Markup plugin to generate from readable syntax that is free of any HTML by default.
Powerful and versatile, while allowing technical writers, for example, to not have to use direct HTML.
Everything is translatable into HTML upon rendering with customizations addable as opt-in.
and more.
First of all it is also more secure and therefore also perfect if not all users are “trustable” admins. It is also twice as fast.
Read the pros and syntax improvements in the linked repo above for details.
Render entire templates written in Djot syntax with .djot extension.
Controller:
// src/Controller/PagesController.php
public function documentation(): void
{
$this->viewBuilder()->setClassName('Markup.Djot');
$this->set('username', $this->Authentication->getIdentity()->username);
}
Template (templates/Pages/documentation.djot):
# Welcome, {{username}}!
This page was rendered from a `.djot` template file.
You can use all djot features:
- *Bold* and _italic_ text
- [Links](https://example.com)
- `Code blocks`
## Features
| Feature | Status |
|---------|--------|
| Tables | Yes |
| Lists | Yes |
| Code | Yes |
Use the Djot helper to render partial content within regular PHP templates.
AppView setup:
// src/View/AppView.php
public function initialize(): void
{
$this->addHelper('Markup.Djot');
}
Template usage:
// templates/Articles/view.php
<div class="article-content">
<?= $this->Djot->convert($article->body) ?>
</div>
<aside class="sidebar">
<?= $this->Djot->convert($article->summary) ?>
</aside>
Ideal for flash messages, notifications, or any small dynamic content.
Flash messages:
// src/Controller/UsersController.php
public function register(): void
{
if ($this->request->is('post')) {
// ... save logic
$this->Flash->success('Account created! Check your _email_ for the *activation link*.');
}
}
Custom flash element (templates/element/flash/default.php):
<?php
/** @var string $message */
?>
<div class="flash-message <?= h($class ?? 'info') ?>">
<?= $this->Djot->convert($message) ?>
</div>
Helper methods for inline content:
// In any template
$notice = '_Note:_ This action *cannot* be undone.';
echo $this->Djot->convert($notice);
// Tooltips or help text
$helpText = 'Use `Ctrl+S` to save or `Ctrl+Z` to undo.';
echo $this->Djot->convert($helpText);
If you’ve ever wished Markdown was a bit more consistent and feature-rich, you’ll want to hear about Djot – and now there’s a complete PHP implementation available.
Djot is a lightweight markup language by the author of Commonmark (Markdown) and Pandoc. It takes the best ideas from Markdown while addressing many of its ambiguities and limitations. The syntax is familiar yet more predictable, making it an excellent choice for content-heavy applications. You could call it somewhat a possible successor.
The php-collective/djot composer package brings full Djot support to PHP 8.2+, with 100% compatibility with the official djot test suite.
Let’s talk about common cases where such a markup language would be beneficial:
Let’s see if Djot fits these needs.
Djot supports the familiar emphasis and strong formatting, plus several extras:
| Syntax | Result | Description |
|---|---|---|
*Strong* |
Strong | Bold text |
_Emphasized_ |
Emphasized | Italic text |
{=Highlighted=} |
Highlighted | Highlighted text |
{+Inserted+} |
Inserted | Inserted text |
{-Deleted-} |
Deleted text | |
`code` |
code |
Inline code |
E=mc^2^ |
E=mc2 | Superscript |
H~2~O |
H2O | Subscript |
Smart quotes, em-dashes, en-dashes, and ellipsis are handled automatically:
"Hello" becomes “Hello” with curved quotes
--- becomes an em-dash (—)
-- becomes an en-dash (–)
... becomes an ellipsis (…)
Full table support with column alignment:
| Feature | Status | Notes |
|:------------|:------:|--------:|
| Left-align | Center | Right |
Native checkbox support for task lists:
- [x] Create parser
- [x] Create renderer
- [ ] World domination
Since this post is written in Djot, here’s the actual rendered output:
Create styled containers with the triple-colon syntax:
::: warning
This is a warning message.
:::
Renders as:
<div class="warning">
<p>This is a warning message.</p>
</div>
Live demo:
Note: This is a note block. Use it for tips, hints, or additional information that complements the main content.
Warning: This is a warning block. Use it to highlight important cautions or potential issues that readers should be aware of.
Add classes, IDs, or custom attributes to inline content:
This is [important]{.highlight #key-point}
Fenced code blocks with syntax highlighting hints:
```php
$converter = new DjotConverter();
echo $converter->convert($text);
```
The ^ prefix adds a caption to the block immediately above it:
| Block Type | HTML Output |
|---|---|
| Image | <figure> + <figcaption> |
| Table | <caption> inside <table> |
| Blockquote | <figure> + <figcaption> |
> To be or not to be,
> that is the question.
^ William Shakespeare
Renders as:
To be or not to be,
that is the question.
Let’s be honest: Markdown has quirks. Ever spent 20 minutes debugging why your nested list won’t render correctly? Or wondered why _this_works_ but _this_doesn't_ in some parsers?
Djot was designed by someone who knows these pain points intimately – John MacFarlane literally wrote the CommonMark spec. With Djot, he started fresh with lessons learned from years of Markdown edge cases.
The result? A syntax that feels familiar but actually behaves predictably. Your users write content, not workarounds.
<b>...</b> shouldn’t be treated as such blindly
| Feature | Markdown | Djot |
|---|---|---|
| Strong | **text** or __text__ |
*text* |
| Emphasis | *text* or _text_ |
_text_ |
| Highlight | (needs extension) |
{=text=} |
| Insert/Delete | (needs extension) |
{+text+} / {-text-} |
| Attributes | (non-standard) |
[text]{.class #id} |
| Divs | ![]() |
::: classname |
| Smart quotes | Depends on parser | Always on |
| Nested emphasis | Inconsistent | Predictable |
| Hard line breaks | Two trailing spaces | Visible \ (backslash) |
Trailing spaces are problematic since most IDEs and editors auto-trim whitespace. Using a visible \ character is much cleaner.
Auto-HTML is also problematic for user-generated content. Djot treats everything as text by default – you must explicitly enable raw HTML (see below).
Converting Djot to HTML is straightforward:
use Djot\DjotConverter;
$converter = new DjotConverter();
$html = $converter->convert($djotText);
Need XHTML output? Just pass a flag:
$converter = new DjotConverter(xhtml: true);
For more control, you can work with the AST directly:
$converter = new DjotConverter();
// Parse to AST
$document = $converter->parse($djotText);
// Manipulate the AST if needed...
// Render to HTML
$html = $converter->render($document);
Note: This is specific to this library and not yet officially in the specs.
Using this in your apps means, your users get the best out of both concepts, but it also means you need to clarify and document this and cannot “just” link to djot specs.
Configure soft breaks as per context and user needs:
| Mode | HTML Output | Browser Display |
|---|---|---|
| Newline | \n |
No visible break (whitespace collapsed) |
| Space | |
No visible break (whitespace collapsed) |
| Break | <br> |
Visible line break |
$renderer = $converter->getRenderer(); // HtmlRenderer
// Default - newline in source, invisible in browser
$renderer->setSoftBreakMode(SoftBreakMode::Newline);
// Space - same visual result, slightly smaller HTML
$renderer->setSoftBreakMode(SoftBreakMode::Space);
// Break - every source line break becomes visible <br>
$renderer->setSoftBreakMode(SoftBreakMode::Break);
This actually allows a certain compatibility with users that are used to Markdown line breaking within normal text. So this is useful for chats or simple text inputs.
As this only affects the rendering, but not the parsing, this is still fully spec-compliant in that way.
This mode is for users accustomed to Markdown’s “human” behavior where newlines intuitively interrupt blocks.
The Djot specification states: “Paragraphs can never be interrupted by other block-level elements.”
In standard Djot, this means lists and other elements require blank lines before them – more “spaced” than what Markdown users expect.
There’s an easy solution to get the best of both worlds:
$converter = new DjotConverter(significantNewlines: true);
$result = $converter->convert("Here's a list:
- Item one
- Item two");
// Output: <p>Here's a list:</p>\n<ul><li>Item one</li><li>Item two</li></ul>
If you need a marker character (-, *, +, >) at the start of a line without triggering a block, use escaping:
// Without escaping - creates a list
$result = $converter->convert("Price:
- 10 dollars");
// Output: <p>Price:</p><ul><li>10 dollars</li></ul>
// With escaping - literal text
$result = $converter->convert("Price:
\\- 10 dollars");
// Output: <p>Price:<br>- 10 dollars</p>
This returns you to standard Djot behavior for that line.
This mode is useful when migrating existing systems where users expect Markdown-like behavior – most content works without changes, and the rare edge cases can be escaped.
For offline docs and anything needed to be more agnostic one should still use the default spec compliant way.
The library includes a clean, modern extension system, making common features trivial to add:
use Djot\DjotConverter;
use Djot\Extension\ExternalLinksExtension;
use Djot\Extension\TableOfContentsExtension;
use Djot\Extension\DefaultAttributesExtension;
$converter = new DjotConverter();
$converter
->addExtension(new ExternalLinksExtension())
->addExtension(new TableOfContentsExtension(position: 'top'))
->addExtension(new DefaultAttributesExtension([
'image' => ['loading' => 'lazy'],
'table' => ['class' => 'table table-striped'],
]));
| Extension | Description |
|---|---|
AutolinkExtension |
Auto-links bare URLs and email addresses |
DefaultAttributesExtension |
Adds default attributes by element type (lazy loading, CSS classes) |
ExternalLinksExtension |
Adds target="_blank" and rel="noopener noreferrer" to external links |
HeadingPermalinksExtension |
Adds clickable anchor links (¶) to headings |
MentionsExtension |
Converts @username patterns to profile links |
TableOfContentsExtension |
Generates TOC from headings with optional auto-insertion |
The DefaultAttributesExtension is particularly useful:
$converter->addExtension(new DefaultAttributesExtension([
'image' => ['loading' => 'lazy', 'decoding' => 'async'],
'table' => ['class' => 'table table-bordered'],
'block_quote' => ['class' => 'blockquote'],
]));
Extensions can also be combined. For example, AutolinkExtension should be registered before ExternalLinksExtension so auto-linked URLs also get the external link attributes.
For more control, use the event system directly:
use Djot\Renderer\Event\RenderEvent;
$renderer = $converter->getRenderer();
// Convert :emoji: symbols to actual emoji
$renderer->addEventListener('render.symbol', function (RenderEvent $event) {
$node = $event->getNode();
$emoji = match ($node->getName()) {
'smile' => '😊',
'heart' => '❤',
'rocket' => '🚀',
default => ':' . $node->getName() . ':',
};
$event->setHtml($emoji);
});
// Add target="_blank" to external links
$renderer->addEventListener('render.link', function (RenderEvent $event) {
$link = $event->getNode();
$url = $link->getDestination();
if (str_starts_with($url, 'http')) {
$link->setAttribute('target', '_blank');
$link->setAttribute('rel', 'noopener noreferrer');
}
});
Need #hashtags or wiki-style links? The parser supports custom inline patterns:
use Djot\Node\Inline\Link;
use Djot\Node\Inline\Text;
$parser = $converter->getParser()->getInlineParser();
// #hashtags → tag pages
$parser->addInlinePattern('/#([a-zA-Z][a-zA-Z0-9_]*)/', function ($match, $groups) {
$link = new Link('/tags/' . strtolower($groups[1]));
$link->appendChild(new Text('#' . $groups[1]));
return $link;
});
echo $converter->convert('Check out #PHP and #Djot!');
// <p>Check out <a href="/tags/php">#PHP</a> and <a href="/tags/djot">#Djot</a>!</p>
Custom block patterns are also supported for admonitions, tab containers, and more. See the Cookbook for recipes including wiki links, math rendering, and image processing.
SafeMode prevents XSS attacks, but what about controlling which markup features users can access? A comment section probably shouldn’t allow headings, tables, or raw HTML – not because they’re dangerous, but because they’re inappropriate for that context.
That’s where Profiles come in. They complement SafeMode by restricting available features based on context:
use Djot\Profile;
// Comment sections: basic formatting only
$converter = new DjotConverter(profile: Profile::comment());
// Blog posts: rich formatting, but no raw HTML
$converter = new DjotConverter(profile: Profile::article());
// Chat messages: text, bold, italic - that's it
$converter = new DjotConverter(profile: Profile::minimal());
| Concern | SafeMode | Profile |
|---|---|---|
| Purpose | Security (XSS prevention) | Feature restriction |
| Blocks | javascript: URLs, event handlers |
Headings, tables, raw HTML |
| Target | Malicious input | Inappropriate formatting |
Use both together for user-generated content:
$converter = new DjotConverter(
safeMode: true,
profile: Profile::comment()
);
Each profile is designed for specific use cases:
Profile::full() – Everything enabled (admin/trusted content)
Profile::article() – Blog posts: no raw HTML, allows headings/tables
Profile::comment() – User comments: no headings/tables, adds rel="nofollow ugc" to links
Profile::minimal() – Chat: text, bold, italic only
Profiles can explain why features are restricted:
$profile = Profile::comment();
echo $profile->getReasonDisallowed('heading');
// "Headings would disrupt page hierarchy in user comments"
echo $profile->getReasonDisallowed('raw_block');
// "Raw HTML could bypass template styling and security measures"
Control where users can link to:
use Djot\LinkPolicy;
// Only allow links to your own domain
$profile = Profile::comment()
->setLinkPolicy(LinkPolicy::internalOnly());
// Or whitelist specific domains
$profile = Profile::comment()
->setLinkPolicy(
LinkPolicy::allowlist(['docs.php.net', 'github.com'])
->withRelAttributes(['nofollow', 'ugc'])
);
When users try restricted features, content converts to plain text by default – nothing is lost:
$converter = new DjotConverter(profile: Profile::minimal());
$html = $converter->convert('# Heading attempt');
// Renders: <p>Heading attempt</p> (text preserved, heading stripped)
For stricter handling, you can strip content entirely or throw exceptions:
$profile = Profile::minimal()->setDefaultAction(Profile::ACTION_STRIP);
// Or for APIs:
$profile = Profile::minimal()->setDefaultAction(Profile::ACTION_ERROR);
The package uses a clean separation of concerns:
This AST-based approach makes the codebase maintainable and opens possibilities for alternative output formats.
There are also other compatibility renderers available, as well as converters to convert existing markup to Djot.
Want to use Djot in your WordPress site? There’s now a dedicated plugin that brings full Djot support to WordPress.
[djot]...[/djot] for mixed content
Fun fact: I just migrated this blog from custom markdown-hacks to Djot (and wrote this post with it).
For that I used the built in migrator of that WP plugin as well as a bit of custom migration tooling.
I needed to migrate posts, articles and comments – all in all quite straightforward though.
The new interface with quick markdown-paste and other useful gimmicks helps to speed up technical blogging actually. It is both safe (comments use the right profile) and reliable.
The plugin also comes with useful semantic customization right away:
| Djot Syntax | HTML Output | Output | Use Case |
|---|---|---|---|
[CSS]{abbr="Cascading Style Sheets"} |
<abbr title="...">CSS</abbr> |
CSS | Abbreviations |
[Ctrl+C]{kbd=""} |
<kbd>Ctrl+C</kbd> |
Ctrl+C | Keyboard input |
[term]{dfn=""} |
<dfn>term</dfn> |
term | Definition term |
On top, it has some gotchas as extensions:
{video} renders videos from all WP supported sources right away, customize the attributes as always: {video width=300 height=200}
You can extend the customizations also on your own.
For developers using PhpStorm, IntelliJ IDEA, or other JetBrains IDEs, there’s now
an official Djot plugin available.
.djot files
The plugin requires JetBrains IDE 2024.1+ and Java 17+.
The library and the WP plugin already have some useful enhancements beyond the spec:
These extend beyond the current spec but are documented as such. Keep this in mind if you need cross-application compatibility.
There is a highlight.js extension available to also code highlight djot content.
How fast is it? We benchmarked djot-php against Djot implementations in other languages:
| Implementation | ~56 KB Doc | Throughput | vs PHP |
|---|---|---|---|
| Rust (jotdown) | ~1-2 ms | ~30+ MB/s | ~10x faster |
| Go (godjot) | ~2-4 ms | ~15+ MB/s | ~5x faster |
| JS (@djot/djot) | ~8 ms | ~7 MB/s | ~2x faster |
| PHP (djot-php) | ~18 ms | ~3 MB/s | baseline |
| Python (markdown-it) | ~37 ms | ~1.5 MB/s | ~2x slower* |
*Python comparison uses Markdown parsers since no Djot implementation exists for Python.
Key observations:
For typical blog posts and comments (1-10 KB), parsing takes under 5 ms. A 1 MB document converts in ~530 ms using ~44 MB RAM.
The performance documentation includes detailed benchmarks, memory profiling, and stress test results.
It is also interesting to compare it with other PHP parsers, usually markdown obviously:
| Library | 27KB Doc | Throughput | vs djot-php |
|---|---|---|---|
| erusev/parsedown | 1.73 ms | 15.6 MB/s | 5.9x faster |
| michelf/php-markdown | 5.26 ms | 5.1 MB/s | 1.9x faster |
| michelf/php-markdown (Extra) | 6.12 ms | 4.4 MB/s | 1.7x faster |
| djot-php | 10.22 ms | 2.6 MB/s | baseline |
| league/commonmark | 16.17 ms | 1.7 MB/s | 1.6x slower |
| league/commonmark (GFM) | 16.86 ms | 1.6 MB/s | 1.7x slower |
No surprise:
Key finding with equivalent features enabled:
| Library | Time | vs djot-php |
|---|---|---|
| djot-php | 11.36 ms | baseline |
| CommonMark (GFM) | 15.00 ms | 1.3x slower |
| CommonMark (Full) | 23.54 ms | 2.1x slower |
Djot syntax was designed for efficient parsing
| Feature | djot-php | CommonMark | Parsedown | Michelf |
|---|---|---|---|---|
| Basic formatting | Yes | Yes | Yes | Yes |
| Tables | Yes | GFM only | Yes | Extra |
| Footnotes | Yes | No | No | Extra |
| Definition lists | Yes | No | No | Extra |
| Task lists | Yes | GFM only | No | No |
| Smart typography | Yes | No | No | No |
| Math expressions | Yes | No | No | No |
| Attributes | Yes | No | No | Extra |
| Highlight/Insert/Delete | Yes | No | No | No |
| Super/Subscript | Yes | No | No | No |
| Divs/Sections | Yes | No | No | No |
| Event system | Yes | Yes | No | No |
| Safe mode | Yes | Yes | Yes | Yes |
| Profiles | Yes | No | No | No |
| Extension system | Yes | Yes | No | No |
$x^2$ and display $$ math
{=highlight=}, {+insert+}, {-delete-}
H~2~O, x^2^
::: warning ... :::
<abbr> tags
You can often with a boolean flag just continue to support the current markup, and with new content add djot based content.
For those that want to migrate, there is some built in tooling and converters:
Fun fact: They also serve as a nice round-trip validation, to check if the transformation from and to is loss-free. Send a doc into it and reverse it, and the content should still “match” without loss of supported structures.
The library is actively maintained with plans for:
Contributions welcome!
I would have liked URLs and images to have a bit more friendly syntax as well, e.g.
[link: url "text"] style for links and [image: src "alt"] style for images.
The  style still feels a bit too much like code syntax to me.
If I were ever to invent a new markup language, I would probably take a similar approach,
but try to keep it even simpler by default.
The {} braces seem a bit heavy for these common use cases, and for non-technical users.
One of the quirks I had to get used to, was the automated flow (line breaks are ignored) and the need for the visible (hard) line break if really desired. But in the end it usually helps to keep clear paragraphs. And I added compatibility options as opt-in for upgrading or usability ease.
Overall, Djot strikes a great balance between familiarity and consistency.
And at least topics like URL/image can be easily added as extension if desired.
The PHP implementation with djot-php library is the most complete implementation of the standard available. It is perfectly suited for web-based usage. Make sure to check out the live sandbox and play around with the complex examples!
Give Djot PHP a try in your next project.
The familiar syntax with improved consistency and a lot more out of the box might just win you over.
A Modern File Storage Solution for CakePHP.
Remember when handling file uploads in PHP meant wrestling with $_FILES, manually moving uploaded files, hardcoding paths throughout your application, and hoping nothing breaks when you need to move files to S3? We’ve all been there.
For years, CakePHP developers have cobbled together various solutions—some rolled their own upload handlers, others patched together different libraries, and the brave ones tried to keep legacy plugins alive across framework versions.
But let’s face it: file management is harder than it looks. You need thumbnails? Add another library. Want to store in the cloud? Rewrite your code. Need to track metadata? Better add more database columns everywhere.
Those days are over.
There’s a new player on the CakePHP block, and it’s bringing modern architecture, clean abstractions, and production-ready code to solve file management once and for all.
Built from the ground up for CakePHP 5.x with PHP 8.1+, the FileStorage plugin isn’t just another upload library—it’s a complete storage solution that finally gets file handling right.
Think of it as the file management system you wish you’d had on your last three projects.
Here’s where it gets interesting. The plugin is built on FlySystem v3, which means your files can live anywhere—and I mean anywhere. Local disk today, AWS S3 tomorrow, Azure next month? Just change your config. Your application code? Stays exactly the same.
Start developing locally, deploy to S3 in production, switch to Azure when the business changes cloud providers next year. The plugin doesn’t care, and neither does your code. That’s the power of proper abstraction.
Supported out of the box:
Local filesystem (the classic)
Amazon S3 (the popular choice)
Azure Blob Storage (the enterprise favorite)
FTP/SFTP (for that legacy server)
Dropbox (why not?)
In-memory storage (perfect for testing)
Any other backend via FlySystem adapters
One interface. Any storage. Zero refactoring.
Remember writing upload handling code in every controller action? Yeah, me too. Not anymore.
Attach the FileStorageBehavior to your model, and watch the magic happen. Files upload when you save. Files delete when you remove entities. It just works, like it should have from the beginning:
// In your Table class - that's it!
public function initialize(array $config): void
{
parent::initialize($config);
$this->addBehavior('FileStorage.FileStorage', [
'fileField' => 'file',
]);
}
Your controllers stay clean. Your code stays simple. Your future self says thank you.
Here’s something clever: instead of adding avatar_path, document_path, and thumbnail_path columns to every table in your database (we’ve all done it), the plugin uses a single file_storage table for all file metadata.
Think about it. Your users table doesn’t need file paths. Your products table doesn’t need file paths. They just have relationships to the FileStorage model, like any other association in CakePHP.
What you get:
Clean schema – No path strings cluttering your business data
Easy migrations – Move files around without touching your core tables
Perfect audit trail – Every upload tracked with metadata, hashes, timestamps
Duplicate detection – File hashes catch duplicate uploads automatically
Flexible relationships – One file, many owners? No problem.
It’s the architecture you’d design if you had time to think it through properly. Good news: someone already did.
“Just generate a thumbnail” said the client, as if it’s simple. Except now you need three sizes. And they want them optimized. And cropped to exact dimensions. And…
Stop. Take a breath. The plugin’s got you covered.
With the optional php-collective/file-storage-image-processor package (powered by Intervention Image v3), you can generate any variant you need with a fluent, chainable API that actually makes sense:
$variants = ImageVariantCollection::create()
->addNew('thumbnail')
->scale(300, 300)
->optimize()
->addNew('medium')
->scale(800, 600)
->sharpen(10)
->optimize()
->addNew('profile')
->cover(150, 150)
->optimize();
$imageProcessor->process($file, $variants);
Upload one image, get four versions (original + three variants), all optimized, all stored, all tracked. One line of code to process them all.
The full toolkit:
scale – Maintain aspect ratio (the one you actually want most of the time)
resize – Exact dimensions (when you really mean it)
cover – Smart zoom-crop (perfect for profile pics)
crop – Surgical precision extracts
rotate – Because users upload sideways photos
flip operations – Mirror, mirror on the wall
sharpen – Make those photos pop
optimize – Smaller files, same quality
callback – Your custom wizardry
Chain them, combine them, go wild. The API won’t judge.
The path builder keeps things tidy automatically:
// Generated paths like:
{model}/{collection}/{randomPath}/{id}/{filename}.{extension}
{model}/{collection}/{randomPath}/{id}/{filename}.{hashedVariant}.{extension}
// Example:
// Posts/Cover/a1/b2c3d4e5/my-image.jpg
// Posts/Cover/a1/b2c3d4e5/my-image.abc123.jpg (variant)
Organize by model, by collection, by random path levels—whatever makes sense for your app. The plugin handles the patterns.
Because nobody wants 500MB BMPs crashing their server:
File type and extension checks
Size limits that actually work
MIME type validation
Image dimension constraints
Full PSR-7 support
Set your rules once, enforce them everywhere.
This isn’t a weekend hackathon project. The plugin is architected with production in mind:
Event-driven design means you can hook into any file operation—send notifications, trigger processing, update CDNs, whatever you need.
Dependency injection throughout makes testing actually pleasant and customization straightforward.
Standards-based on FlySystem, Intervention Image, PSR-7, and CakePHP best practices. No weird custom abstractions to learn.
Quality assured with PHPStan level 8, comprehensive tests, and active maintenance.
Your client uploads one massive 5000x5000px product photo. The plugin generates thumbnail, gallery, and zoom versions, optimizes them all, and stores them wherever you want:
$variants = ImageVariantCollection::create()
->addNew('thumbnail')->scale(150, 150)->optimize()
->addNew('gallery')->scale(600, 600)->optimize()
->addNew('zoom')->scale(1200, 1200)->optimize();
Your customers get fast page loads. Your S3 bill stays reasonable. Everybody wins.
User avatars that actually look good everywhere—navbar, profile page, comment sections, notification icons:
$variants = ImageVariantCollection::create()
->addNew('avatar')->cover(200, 200)->optimize()
->addNew('avatar_small')->cover(50, 50)->optimize();
Smart cropping means faces stay centered. Automatic optimization means your CDN costs don’t explode.
PDFs with thumbnail previews. File metadata tracked perfectly. Full audit trails. Version history. And when your startup grows up and needs to move everything to cloud storage? Update the config. Done.
Different storage backend per customer? Different path structures per tenant? Clean data separation? The plugin’s architecture makes it straightforward instead of scary.
Here’s where it gets exciting. The plugin’s architecture isn’t just solid—it’s designed for extension. The event-driven design, flexible metadata system, and clean abstractions mean you can build some impressive features on top.
Asynchronous variant generation
Don’t handle all rendering synchronously, but render only mini-preview, and let the rest be done via background queue.
class QueuedFileStorageBehavior extends FileStorageBehavior
{
/**
* Default configuration
*
* @var array<string, mixed>
*/
protected array $_defaultConfig = [
'queueVariants' => true, // Auto-queue by default
'immediateVariants' => [], // Generate these immediately
'queuedVariants' => [], // Queue these for background
];
...
}
Secure File Serving with Authorization
Here’s something most upload plugins ignore: access control. The FileStorage plugin doesn’t just store files—it provides utilities for serving them securely.
The plugin gives you URL generation and signed URL helpers, but intentionally doesn’t include a one-size-fits-all serving controller. Why? Because your authorization logic is yours. The plugin provides the tools; you implement the rules that make sense for your application.
use FileStorage\Utility\SignedUrlGenerator;
$signatureData = SignedUrlGenerator::generate($entity, ['expires' => strtotime('+1 hour')]);
// Or implement your own serving controller with custom authorization:
// - Ownership-based (only file owner can access)
// - Role-based (admins see everything, users see their department)
// - Related entity access (file visible if parent album is visible)
// - Time-based (files available only during business hours)
// - Combination of above
The FileServing documentation provides complete examples for each pattern. You get the security infrastructure without the opinionated authorization that never quite fits your needs.
Custom Metadata & Advanced Search
The centralized storage table includes metadata fields you can extend. Add tags, categories, descriptions, or any custom data. Then search, filter, and organize files however your application needs:
$entity = $fileStorage->newEntity([
'file' => $uploadedFile,
'metadata' => [
'tags' => ['product', 'winter-2024'],
'department' => 'Marketing',
'project_code' => 'PRJ-123',
],
]);
File Versioning
Want to track file versions? The architecture supports it naturally through the metadata and collection fields. Store multiple versions with relationships, track upload timestamps, and let users restore previous versions. The collections system makes it clean:
// Store new version, link to original via metadata
$newVersion = $fileStorage->newEntity([
'file' => $newFile,
'model' => 'Documents',
'collection' => 'versions',
'foreign_key' => $document->id,
'metadata' => [
'original_file_id' => $originalFile->id,
'version' => 2,
],
]);
The plugin has an active roadmap shaped by real-world usage. Here’s what’s being discussed and built:
Smart Image Optimization – Auto-format selection (WebP when supported), responsive image sets for different screen sizes, automatic quality adjustment based on file size targets.
Bulk Operations – Select and process multiple files at once. Batch tagging, bulk downloads as ZIP, mass deletions, metadata updates across hundreds of files.
Storage Analytics – Dashboard showing storage usage by type, upload trends, file age distribution, and storage costs. Great for resource planning and quota management.
CDN Integration Helpers – Simplified configuration for CloudFront, CloudFlare, and other CDNs. Automatic cache invalidation, signed URL generation, geo-routing support.
Advanced Transformations – Watermarking pipelines, image filter presets (vintage, black-and-white, etc.), format conversion workflows, PDF thumbnail generation.
Access Control Layers – Permission checks before downloads, user-specific file visibility, role-based access to collections, audit logging for compliance.
Client-Side Widgets – Drop-in components for drag-and-drop uploads with progress bars, inline image cropping, preview before upload, chunked uploads for large files.
You’re not locked into version 4.0’s feature set. The architecture is built to grow with your needs:
Start simple – Basic uploads and storage
Add complexity gradually – Implement versioning when you need it
Customize freely – The event system lets you hook into everything
Stay updated – Active development means new features land regularly
The plugin isn’t just solving today’s file storage problems—it’s built to handle tomorrow’s requirements too.
composer require dereuromark/cakephp-file-storage
Want image processing too?
composer require php-collective/file-storage-image-processor
Load the plugin:
// src/Application.php
public function bootstrap(): void
{
parent::bootstrap();
$this->addPlugin('FileStorage');
}
Run migrations (creates the file_storage table):
bin/cake migrations migrate -p FileStorage
Configure your storage programmatically (typically in bootstrap or a dedicated config file):
// config/storage.php or config/bootstrap.php
use PhpCollective\Infrastructure\Storage\StorageAdapterFactory;
use PhpCollective\Infrastructure\Storage\StorageService;
use PhpCollective\Infrastructure\Storage\Factories\LocalFactory;
use PhpCollective\Infrastructure\Storage\FileStorage;
use PhpCollective\Infrastructure\Storage\PathBuilder\PathBuilder;
$storageFactory = new StorageAdapterFactory();
$storageService = new StorageService($storageFactory);
$storageService->addAdapterConfig('Local', LocalFactory::class, [
'root' => WWW_ROOT . 'files' . DS,
]);
$pathBuilder = new PathBuilder();
$fileStorage = new FileStorage($storageService, $pathBuilder);
Configure::write('FileStorage.behaviorConfig', [
'fileStorage' => $fileStorage,
]);
// In your controller - that's the whole thing
public function upload()
{
if ($this->request->is('post')) {
$file = $this->request->getData('file');
$fileStorage = $this->fetchTable('FileStorage.FileStorage');
$entity = $fileStorage->newEntity([
'file' => $file,
'model' => 'Products',
'foreign_key' => $product->id,
]);
if ($fileStorage->save($entity)) {
$this->Flash->success('File uploaded successfully');
}
}
}
That’s it. No hidden steps. No weird gotchas. It just works.
Let’s be honest: file management is one of those problems that sounds simple until you actually try to solve it properly. Then it becomes a mess of edge cases, path handling, storage migrations, and “we need thumbnails now” feature requests.
The FileStorage plugin exists because developers kept solving the same problems over and over, poorly, under deadline pressure. Someone finally said “enough” and built the solution we all wish we’d had from the start.
Burzum (Florian) started this project, and I just happened to finish it up, test it with real apps, and publish it for everyone to use.
Production-grade code – PHPStan level 8, comprehensive tests, real-world battle-testing. This isn’t beta software.
Flexible from day one – Start local, scale to cloud, switch providers. The architecture doesn’t care where your files live.
Time back in your life – Stop reinventing uploads. Stop debugging path concatenation. Stop explaining to clients why migrating storage is hard.
Community-proven – Built by CakePHP veterans, used in production apps, improved by real-world feedback.
This isn’t a dusty old plugin getting by on legacy code. Version 4.0 brings modern everything:
CakePHP 5.x native – Built for the latest framework
FlySystem v3 – The best storage abstraction available
Stable dependencies – Everything’s at 1.0.0+
Intervention Image v3 – Latest image processing with improved performance
Fresh code. Modern patterns. Zero compromises.
Words are cheap. Code is proof.
The live demo shows real uploads, real image processing, real variant generation. Upload a file, watch it generate thumbnails, see how the API works. No registration, no signup forms, just working code you can play with.
Try the live demo: https://sandbox.dereuromark.de/sandbox/file-storage-examples
Want the technical details, changelog, and upgrade notes?
Release notes: https://github.com/dereuromark/cakephp-file-storage/releases/tag/4.0.0
Here’s a bonus: the core libraries powering this plugin aren’t tied to CakePHP. They’re framework-agnostic PHP packages that work in any project.
The plugin itself (dereuromark/cakephp-file-storage) is a convenient CakePHP wrapper that adds behaviors, helpers, and framework integration. But under the hood, it’s built on:
php-collective/file-storage – The core storage abstraction and file management
php-collective/file-storage-factories – Simplified FlySystem adapter configuration
php-collective/file-storage-image-processor – Image processing and variant generation
All three packages work standalone in any framework, or plain PHP projects.
They use standard PSR interfaces, Dependency Injection, and have zero framework dependencies.
What this means:
If you’re building a CakePHP application, use the plugin. You get behaviors, table integration, migrations, and everything configured to work with CakePHP conventions out of the box.
If you’re working with another framework, use the underlying libraries directly. Same powerful file storage and image processing, just without the CakePHP sugar coating.
If you’re migrating between frameworks, your file storage logic can stay the same. Only the wrapper layer changes.
The architecture is portable. The investment is protected. Build once, use anywhere.
File storage is a solved problem now. You don’t need to solve it again.
Whether you’re building a simple blog with avatars, a document management system, an e-commerce platform, or the next big SaaS app—the FileStorage plugin gives you production-ready file handling that actually scales.
Install it. Configure it. Forget about it. Move on to building features that actually matter.
Welcome to modern file storage for CakePHP.
More than 5 years ago I mentioned it as a side topic.
Now I want to showcase it with fresh look and feel and lots of more useful tooling.
It now ships with its own default standalone template.
That makes it easier to “work” and “look correctly”, as the app itself is less likely to conflict here.

A super useful tool you can opt-in for your CakePHP apps to make sure they are up to date and use best practice approaches.
Use the built in tools, and also add your own tasks on top.

Add it to your CI as
bin/cake linter
You can of course also run it on your plugins:
bin/cake linter -p all
For some tasks auto-fixing is possible, in that case run it locally to auto-fix those issues found:
bin/cake linter --fix
You recall the 2.x days? Yes, there was a browser-based (click and run) test runner included.
It lives on in this plugin.
Why is this a good thing? In many cases clicking a link can be much quicker than manually typing or copy pasting the whole path to the test file.
It also directly links the coverage to that file.
But even if you want to keep the CLI for testing, the GUI offers a few more things:
See all CakePHP types as overview (controller, components, tables, helpers, forms, mailers, …) and which of those have test cases and which don’t.
With a simple click bake the missing test cases directly from that backend. You read that right: It also bakes from the GUI.

A handy tool for e.g.
$this->redirect()
HtmlHelper::link() and UrlHelper::build() usage
$this->Form->postLink()
$this->get() and $htis->post() in tests
etc. All of those should get an array instead of plain strings for compatibility.

You can also bake missing fixture factories per click from the backend.
Provides a multi-step process to ensure a fresh migration file replacing your current ones (merging all together).
Works on a tmp-DB ton confirm the result as diff in the end.
The browser based addons can help for test-driven development, the CLI tools for asserting high quality.
Check out the plugin now.
With increasing complexity of apps and more and more requirements on your server and tooling, it can be easily overlooked, when doing updates or migrating servers.
I highly recommend a way to track your requirements.
Some are easier verified, e.g. extensions, as composer would already complain on “install”.
Others like memcache or third party tools could be overlooked until maybe someone notices eventually.
My recommendation:
Use Setup plugin and its healthchecks to run on changes to your server, but also in general periodically to see if all is still up and well.
Often enough somehow the postmaxsize or uploadmaxsize got reset or forgotten for either CLI or web, and suddenly the uploads didn’t work anymore for users (default often is ridiculous 2MB). Or someone forget the composer install on the server, and the version in the composer file did not match (yet) the lock file.
All of those things can be checked as well, and most of those ship out of the box here as convenient checks.
| Web | CLI |
|---|---|
![]() |
![]() |
You can define your own custom checks on top, or adjust/replace the default ones.
See docs for details.
If you ever accidentally deleted some data, or even modified it and you wanted to look what the old value was: It is usually gone.
There is an easy Cake solution to it: Audit logs.
Imagine it just silently tracks all the changes for a specific table, or even all your relevant tables.
And if you wanted to restore a deleted record, or parts of it, it would be right there only 1 click away.

Storage here is usually cheap and especially if those records are important to track any change on, this comes quite easy and fast.
It can be useful to go back in time and find out who changed what at what time, and maybe ask that person about details on this.
I personally like to log-rotate the ones that are not business critical. They would just be around for months or weeks maybe, depending on the record modifications per table.
Checkout the AuditStash plugin for details. It also links the live sandbox demo you can play around with.
You most likely have read my previous posts that go into detail regarding CakePHP and how to be most efficient in the IDE, as well as most “type safe” using PHPStan and static analyzing. See e.g. here.
In this post I want to share some more recent learnings and how I deal with this right now for the absolute best results.
Every command is usually shortcut aliased in composer scripts section.
And I added an alias to my ddev env:
hooks:
post-start:
- exec: echo 'alias c="composer"' >> ~/.bashrc
This way I can run composer update with c u.
If you only touched 3-4 files, running the full tooling battery over all your code can be many minutes of unnecessary waiting time.
What if we ran those only over touched files?
This obviously works best for “per file” checks, and in our case “phpcs” (code style checking and fixing).
The other tools (phpstan, tests, annotations) can also miss side-effects to other unrelated files. So here it is a trade off between fast and reliable.
Often, if you have CI running, it would catch the side-effects and related but untouched files anyway.
So the trade-off can be worth it.
I want to show you how I manage e.g. phpcs here:
"scripts": {
...
"check": [
"@test",
"@stan",
"@cs-check",
"@annotations"
],
"cs-modified": "sh cs-modified.sh",
"cs-check": "phpcs --colors",
"cs-fix": "phpcbf --colors",
"stan": "phpstan analyze",
"test": "phpunit --colors=always",
"test-coverage": "phpdbg -qrrv vendor/bin/phpunit --coverage-html webroot/coverage/",
"annotations": "bin/cake annotate all -r",
"setup": "bin/cake generate code_completion && bin/cake generate phpstorm"
This is in my composer.json files of all projects.
Note the “cs-modified” script.
You can run c cs-m to quickly fix CS issues in the files you touched.
On top of that you can do something similar with annotator, as well as “stan”.
Only tests is a bit more tricky since the files touched on project level, might not be the same as in the tests.
Here is an example implementation:
#!/bin/bash
# Get list of modified and staged PHP files
MODIFIED_FILES=$(git diff --name-only --diff-filter=ACMRTUXB HEAD src/ tests/ config/ plugins/ | grep -E '\.php$')
# Exit if no PHP files are modified
if [ -z "$MODIFIED_FILES" ]; then
echo "No modified PHP files found."
exit 0
fi
echo "Running Code Sniffer Fixer on modified PHP files..."
vendor/bin/phpcbf $MODIFIED_FILES
echo "Checking remaining issues..."
vendor/bin/phpcs $MODIFIED_FILES
The CI, as outlined before, runs the full battery (read only):
- php composer.phar install --no-interaction
- vendor/bin/phpunit
- vendor/bin/phpstan
- vendor/bin/phpcs
- bin/cake annotate all -r -d --ci
The “annotate” CI availability is quite new.
Especially together with PHPStan this really helps to keep your false positive “green”s to a minimum, as without PHPStan reads your “outdated” annotations and based on that gives its OK.
Having the annotations aligned with the actual code and DB fields is crucial if you want PHPStan to find potential bugs after refactoring.
The default level 7 or 8 usually silently skip the undefined vars as well as mixed types.
This can lead to CI being happy but your code actually is broken. See the above blog posts for details.
So I usually install (on top of phpstan itself):
composer require --dev cakedc/cakephp-phpstan
composer require --dev rector/type-perfect
composer require --dev phpstan/phpstan-strict-rules
I recommend the following phpstan.neon setup:
includes:
- vendor/phpstan/phpstan/conf/bleedingEdge.neon
- vendor/cakedc/cakephp-phpstan/extension.neon
- vendor/rector/type-perfect/config/extension.neon
- vendor/phpstan/phpstan-strict-rules/rules.neon
parameters:
level: 8
paths:
- src/
bootstrapFiles:
- config/bootstrap.php
type_perfect:
no_mixed_property: true
no_mixed_caller: true
treatPhpDocTypesAsCertain: false
ignoreErrors:
- identifier: missingType.generics
- identifier: missingType.iterableValue
# Can be ignored to find actual issues for now
- identifier: typePerfect.noArrayAccessOnObject
strictRules:
disallowedLooseComparison: false
booleansInConditions: false
booleansInLoopConditions: false
uselessCast: false
requireParentConstructorCall: false
disallowedBacktick: false
disallowedEmpty: false
disallowedImplicitArrayCreation: true # Here!
disallowedShortTernary: false
overwriteVariablesWithLoop: true # Also good
closureUsesThis: false
matchingInheritedMethodNames: false
numericOperandsInArithmeticOperators: false # !
strictFunctionCalls: true # Also good
dynamicCallOnStaticMethod: true # Here
switchConditionsMatchingType: false
noVariableVariables: false
strictArrayFilter: true # Also good
illegalConstructorMethodCall: true # Also good
We mainly need disallowedImplicitArrayCreation and dynamicCallOnStaticMethod here.
Many of the others are actually not always good practice or overkill. So I ignore those.
If it is a new or quite clean project, I usually also enable a few more stricter checks, e.g.
type_perfect:
null_over_false: true
I hope this gave a bit of an overview on current tooling enhancements for your project to maximize development speed while also making sure PHPStan finds most of the issues right away that are harder to spot and might then hit you in production environment.
There is a new CakePHP session authenticator on the block.
Historically, the Session provider in CakePHP 3.x times using the AuthComponent stored an array of your user (entity) in the session upon login.
It was accessed using Auth.User, so Auth.User.id was the user’s ID.
With the split and adaptation of separate auth plugins and components, the data here became an Identity object, and in the session the
user got stored as the User entity object itself.
The session key also changed to Auth, meaning the ID would now be accessed as Auth.id, since Auth is the session key for the User entity (identity) directly.
On top of that, the access is now purely through the Identity in the request object, as it could also always come from Cookie or other authenticators.
This User session now being an Entity object makes things more problematic.
As now we have more objects in the session directly (serialized). Not just DateTime and (backed) enums, but also User entity and possible contained relations.
If any of those change just slightly upon a deployment, whole sessions would be wiped out due to the unserialization not working out anymore.
These session invalidations can, of course, be mitigated to some extent by adding Cookie auth on top.
For the last years, I stuck to my TinyAuth.Auth component.
So those changes didn’t really affect me.
But when I needed to actually integrate some new apps with the plugin approach, I started to look more into it.
In the process, I also rewrote the authenticators to prefer the identifier directly (normal dependency inversion), as this is also a safer approach.
Using a shared IdentifierCollection seems not only overkill, but it can also be harmful if you by accident have identifier keys colliding.
So best to stick to one specific Identifier (collection) per Authenticator. This has been released as v3.3.0 now.
For details on how the authenticator works, see the official docs.
One idea I looked into recently was to always toArray() or json_encode() the session auth data before storing it.
And restoring it upon read.
From the outside the authenticator would not be any different.
This also worked actually, as this PR shows.
There were concerns that this could be an issue in edge cases, and that not all fields can be safely serialized.
E.g. blob/binary data in a column because its encrypted (and gets en/decrypted by table events).
So I eventually dropped this approach.
With those findings in mind, I explored the idea of storing only the user id in the session and freshly building the identity from it using the DB users table data.
It is working quite nicely:
$service->loadAuthenticator('Authentication.PrimaryKeySession', [
'identifier' => [
'Authentication.Token', [
'dataField' => 'key', // incoming data
'tokenField' => 'id', // lookup for DB table
'resolver' => 'Authentication.Orm',
],
],
'urlChecker' => 'Authentication.CakeRouter',
'loginUrl' => [
'prefix' => false,
'plugin' => false,
'controller' => 'Users',
'action' => 'login',
],
]);
This has another positive side effect: The data is now always up to date, no more issues when
I personally always use a findActive custom finder on top, to prevent logins of not activated or blocked users:
'identifier' => [
'Authentication.Token', [
'dataField' => 'key', // incoming data
'tokenField' => 'id', // lookup for DB table
'resolver' => [
'className' => 'Authentication.Orm',
'finder' => 'active',
],
],
],
with e.g. in UsersTable:
public function findActive(SelectQuery $query): SelectQuery
{
return $query->where(['email_verified IS NOT' => null]);
}
If you need to actually also fetch related data into the identity and contain e.g. Roles or alike, you can also wrap this as
'finder' => 'auth',
This allows you to use both contain() and where() in the same finder.
If you use a custom auth finder that does quite a few extra joins and query, e.g.
you might want to add a Cache layer in between to mitigate the constant DB queries.
Then it will fetch this larger dataset from a quick (ideally memcache or redis) cache, and only require DB lookup once the data changed
and the cache got invalided.
If you use caching, you need to do the invalidation yourself. It is still much easier than having to manually rewrite the identity into the session.
All you need to do is SessionCache::delete($uid);, given that you configured it using Configure.
Once the cached session data cannot be found, it will just look it up in the DB again and then re-cache for the next request.
Why I recommend switching to PrimaryKeySession:
Things to look out for:
The official CakePHP plugin has the base version available, if you want caching included, use the TinyAuth plugin’s “extended edition”.
Docs: github.com/dereuromark/cakephp-tinyauth/blob/master/docs/AuthenticationPlugin
Some useful database commands have been added to Setup plugin 3.8.0+:
bin/cake db init
This will create the (default) database, if it does not yet exist.
Setting up a fresh project or local version of it, the dev tool might not do this automatically.
Running this command avoids having to do this manually.
You can also do the same for the test DB using -c test option.
bin/cake db reset
This will empty all tables of that (default) database, leaving all (phinx) migration tables in place (to avoid rerunning migrations).
bin/cake db wipe
This will clear out all tables of that (default) database.
I for example sometimes need that when running tests locally, having to quickly fix up the migration file and then rerunning the tests.
Since they use the already run migrations and there is no clean rollback for the tests, there is only the wipe option:
bin/cake db wipe -c test
Also sometimes the tests just got messed up somehow, then resetting quickly this way also often makes it work again.
When working with templating, useful helper methods can make your life easier.
A yesNo() helper method can quickly switch between check/error font icons.
For e.g. green/red coloring for this yes/no on top, we can use a ok() helper method to wrap this.

echo $this->Templating->ok(
$this->IconSnippet->yesNo($subscription->enabled),
$subscription->enabled,
);
Details see cakephp-templating/releases/tag/0.2.7
I posted about the usefulness of wrapping HTML in a value object a while back.
Now, having to deal with use statements all over, and especially in templates can be cumbersome.
So maybe in those cases having shortcut functions can be useful.
You can define them in your bootstrap (files):
function html(string $string): \Templating\View\HtmlStringable {
return \Templating\View\Html::create($string);
}
Then in any template, you can use them (given that you use the Templating helpers/traits):
echo $this->Html->link(html(<b>Title</b>), ['action' => 'view', $entity->id]);
Note how you can prevent options (escape true/false) from being needed, which will also improve upgradability.
Did you ever have to do a range filter (min/max), e.g. on prices in a table?
The Search plugin is really powerful and flexible.
Let’s give it a go:
Here we need to make one of the two a dummy one just so the value is parsed into the “args”.
The other rule then does the actual filtering and then has also access to that other value.
->callback('price_max', [
'callback' => function (SelectQuery $query, array $args, $filter) {
return false;
},
])
->callback('price_min', [
'callback' => function (SelectQuery $query, array $args, $filter) {
$min = (int)$args['price_min'];
$max = (int)($args['price_max'] ?? null);
if (!$min && !$max || $max < $min) {
return false;
}
$query->where(['price >=' => $min, 'price <=' => $max]);
return true;
},
]);
I personally like to display this as a slider with two handles and two hidden form control inputs.
A demo of this can be found in the sandbox.

More filter examples here.