DerEuroMark View RSS

A blog about Frameworks (CakePHP), MVC, Snippets, Tips and more
Hide details



DTOs at the Speed of Plain PHP 2 Mar 9:43 AM (12 days ago)

Table of Contents

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:

  1. raise awareness about array > ArrayObject > DTO performance loss
  2. provide a high-speed alternative to reflection libraries with the same feature set (or more)

The Reflection Tax

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.

What If We Just… Generated the Code?

Here’s a radical idea: what if we did all that reflection once, at build time, and generated plain PHP classes?

Introducing php-collective/dto: The Code-Generation Approach

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.

Why Another DTO Library?

The PHP DTO landscape in 2026 looks like this:

  • Native PHP 8.2+ readonly classes: Manual implementation
  • spatie/laravel-data: Laravel-specific, runtime reflection
  • cuyz/valinor: Framework-agnostic runtime mapper
  • symfony/serializer: Component-based serialization

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?

Basic concept

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.

What You Get

  1. Perfect IDE Support – Real methods = perfect autocomplete, “Find Usages”, refactoring
  2. Excellent Static Analysis – PHPStan/Psalm work without plugins or special annotations
  3. Reviewable Code – Generated classes appear in pull requests
  4. Zero Runtime Overhead – No reflection, no type parsing per instantiation
  5. Framework Agnostic – Works anywhere PHP runs

History

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.

Features That Matter

1. Multiple Configuration Formats

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.

2. Mutable and Immutable Options

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(/* ... */);

3. Smart Key Format Conversion

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);

4. Collections with Type Safety

<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(...);

5. Deep Nesting and Safe Access

$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

6. TypeScript Generation

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.

7. Field Tracking for Partial Updates

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);

8. OrFail Methods for Non-Null Guarantees

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

9. Required Fields

Enforce data integrity at creation:

<field name="id" type="int" required="true"/>
new UserDto(['name' => 'John']);
// InvalidArgumentException: Required fields missing: id

10. Validation Rules

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], ...]

11. Enum Support

<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

12. Value Objects and DateTime

<field name="price" type="\Money\Money"/>
<field name="createdAt" type="\DateTimeImmutable"/>

Custom factories for complex instantiation:

Field::class('date', \DateTimeImmutable::class)->factory('createFromFormat')

13. Transform Functions

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.

14. DTO Inheritance

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

15. Array Shapes

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
  1. IDE Autocomplete$dto->toArray()['na suggests name
  2. Typo Detection$dto->toArray()['naem'] shows error
  3. Type Inference['name' => $name] = $dto->toArray() infers $name as string|null
  4. Destructuring Support – Full type safety when unpacking arrays

16. JSON Schema Generation

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.

Also:

  • Property name mapping via mapFrom() and mapTo() — read from email_address in input, write to emailAddr in output
  • Default values for fields
  • Deprecation annotations (IDE warnings for deprecated fields)
  • Union types support (string|int)
  • Generic collection type hints (@return ArrayObject<int, ItemDto>)
  • Computed/derived fields via traits (getFullName() from firstName + lastName)
  • Schema importer (bootstrap DTOs from JSON schemas or OpenAPI 3.x specifications)
  • JSON serialization via serialize()/unserialize()
  • Doctrine mapper generation (--mapper) for SELECT NEW style constructors
  • Collection adapters (CakePHP, Laravel, Doctrine) via adapter registry

Real-World Patterns

API Response Transformation

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));
}
}

Form Handling with Partial Updates

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 Sourcing with Immutable DTOs

$event = new OrderPlacedDto([
'eventId' => Uuid::uuid4()->toString(),
'aggregateId' => $orderId,
'occurredAt' => new DateTimeImmutable(),
'order' => $orderDto,
]);
// Create corrected version without mutating original
$corrected = $event->withVersion(2);

Performance: The Numbers

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.

Simple DTO Creation (User with 6 fields)

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.

Complex Nested DTOs (Order with User, Address, 3 Items)

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.

Serialization (toArray)

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

Property Access (10 reads)

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.

Mutable vs Immutable Operations

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.

JSON Serialization

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.

Visual Comparison

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 │
└──────────────────────────────────────────────────────────────────┘

Key Insights

  1. php-collective/dto is 25-26x faster than runtime DTO libraries for object creation
  2. Only ~2.2x slower than plain PHP — generated code approaches hand-written performance
  3. Serialization is ~22x faster than spatie/laravel-data — generated toArrayFast() avoids per-field metadata lookups
  4. The performance gap grows with nesting – more nested objects = more reflection overhead for runtime libraries
  5. Can process ~322K complex nested DTOs per second – sufficient for any batch processing scenario
  6. Property access and mutability operations are near-native speed

When to Use php-collective/dto

Choose php-collective/dto when:

  • Performance matters (API responses, batch processing)
  • You want excellent IDE and static analysis support
  • You prefer configuration files over code attributes
  • You need both mutable and immutable DTOs
  • You work with different key formats
  • You want to share types with TypeScript frontends
  • You value reviewable, inspectable generated code

Consider alternatives when:

  • You’re already deep in Laravel and want framework integration (laravel-data)
  • You need advanced validation like conditional rules or cross-field dependencies
  • You want runtime-only, no build step (valinor)

Summary

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.

Migration Path: From Arrays to DTOs

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.

Stage 0: The Array Wilderness

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.

Stage 1: Introduce DTOs at the Boundary

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.

Stage 2: Move DTOs Inward to Service Methods

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.

Stage 3: Replace Internal Array Passing

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

Stage 4: Use Projection for Read-Only Queries

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.

What to Migrate First

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]

Rules of Thumb

  • Don’t convert everything at once. Start with the file you’re already editing.
  • One DTO per PR. Each conversion is a small, reviewable change.
  • Let the pain guide you. If you’ve been burned by a missing array key, that’s where the DTO goes.
  • Keep entities for writes. Entities handle validation, callbacks, and persistence. DTOs handle data transfer. They coexist.
  • Generated DTOs can wrap entities. Use UserDto::createFromArray($entity->toArray()) as a bridge during migration — no need to refactor the query layer first.

Demo

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.

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

Displaying maps in (Cake)PHP 4 Feb 9:22 AM (last month)

Table of Contents

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.

The Google Problem

Google Maps has been the go-to solution for web mapping for years, but the pricing model has become increasingly aggressive:

  • Pay-as-you-go pricing with no hard spending limits by default
  • Static maps, dynamic maps, and geocoding all count separately
  • Costs can spiral out of control with bot traffic or unexpected usage spikes
  • A simple pet project can suddenly generate significant monthly bills

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 Solution: A Multi-Provider Approach

The CakePHP Geo plugin has evolved to support a comprehensive set of alternatives. Instead of being locked into a single provider, you can now:

  • Use completely free open-source tile providers
  • Choose from multiple geocoding services with generous free tiers
  • Mix and match providers for different use cases
  • Set up fallback chains for reliability

Interactive Maps with Leaflet

The new LeafletHelper brings Leaflet.js to CakePHP – a lightweight, open-source JavaScript library that powers maps on thousands of websites.

Basic Usage

// 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();

Tile Provider Freedom

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' => '&copy; OpenStreetMap, &copy; OpenTopoMap',
'maxZoom' => 17,
],
],
]);

Free Tile Provider Comparison

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

Advanced Features

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();

Static Maps Without Google

The new StaticMapHelper generates static map images from multiple providers – perfect for emails, PDFs, or pages where you don’t need interactivity.

Supported Providers

Provider Free Tier Sign-up
Geoapify 3,000/day geoapify.com
Mapbox 50k/month mapbox.com
Stadia 200k/month stadiamaps.com
Google 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.

Usage

$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'],
],
]);

Configuration

Set up your preferred provider globally:

// config/app_local.php
'StaticMap' => [
'provider' => 'geoapify',
'size' => '400x300',
'geoapify' => [
'apiKey' => env('GEOAPIFY_API_KEY'),
'style' => 'osm-bright',
],
],

Geocoding Alternatives

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
],
],

Provider Fallback Chain

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.

Geocoding Provider Comparison

Provider Free Tier API Key Notes
Nominatim 1 req/sec No OSM-based, requires user-agent
Geoapify 3,000/day Yes (free) Good accuracy
Google $200 credit/month Yes Best accuracy, expensive beyond credit

Testing with NullProvider

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.

Migration Guide

Moving from Google-only to multi-provider is straightforward:

1. Interactive Maps: GoogleMapHelper to LeafletHelper

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();

2. Static Maps

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,
]);

3. Geocoding

Before:

'Geocoder' => [
'apiKey' => env('GOOGLE_MAPS_API_KEY'),
],

After:

'Geocoder' => [
'provider' => Geocoder::PROVIDER_GEOAPIFY,
'geoapify' => [
'apiKey' => env('GEOAPIFY_API_KEY'),
],
],

Other New Features

Marker Clustering

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.

Spatial Queries with Index Support

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:

  1. A bounding box pre-filter with ST_Within() leverages spatial indexes to quickly eliminate distant records
  2. 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.

Migration Setup

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().

Keeping Coordinates in Sync

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.

Live Demos

See all these features in action at the Sandbox:

Conclusion

You no longer need to be locked into Google’s pricing model. The CakePHP Geo plugin now provides:

  • LeafletHelper – Full-featured interactive maps with free tile providers
  • StaticMapHelper – Multi-provider static maps with unified API
  • Geocoder – Multiple providers with automatic fallback chains
  • NullProvider – Clean testing without external dependencies

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.

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

CakePHP Tips – 2026 Part 1 25 Jan 12:11 AM (last month)

Table of Contents

Compact CLI output

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

Djot Templating

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.

  • Whole templates using DjotView
  • Partial templates
  • Code snippets or small elements (ideal for e.g. flash messages or alike)

and more.

Why Djot instead of Markdown?

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.

Whole Templates using DjotView

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 |

Partial Templates

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>

Code Snippets and Small Elements

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);

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

Djot PHP: A Modern Markup Parser 8 Dec 2025 8:59 PM (3 months ago)

Table of Contents

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.

What is Djot?

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.

Use Cases

Let’s talk about common cases where such a markup language would be beneficial:

  • Blog engines and CMS platforms
  • Documentation systems
  • Technical writing applications
  • User-generated content (comments, forums) with Profile-based restrictions
  • Any project requiring lightweight markup with advanced formatting
  • Customizable to specific (business relevant) markup/constructs
  • Secure by design

Let’s see if Djot fits these needs.

Feature Highlights

Rich Text Formatting

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 Deleted text
`code` code Inline code
E=mc^2^ E=mc2 Superscript
H~2~O H2O Subscript

Smart Typography

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 (…)

Tables with Alignment

Full table support with column alignment:

| Feature | Status | Notes |
|:------------|:------:|--------:|
| Left-align | Center | Right |

Task Lists

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 parser
  • Create renderer
  • World domination

Divs with Classes

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.

Spans with Attributes

Add classes, IDs, or custom attributes to inline content:

This is [important]{.highlight #key-point}

Code Blocks

Fenced code blocks with syntax highlighting hints:

```php
$converter = new DjotConverter();
echo $converter->convert($text);
```

Captions (Images, Blockquotes & Tables)

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.

William Shakespeare

The Markdown Elephant in the Room

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.

Why Djot Over Markdown?

  • More consistent syntax – Fewer edge cases and ambiguities
  • Better nesting – Clear rules for nested emphasis and containers
  • Built-in features – Highlights, insertions, deletions, and spans without extensions
  • Smart typography – Automatic without additional plugins
  • Cleaner specification – Easier to implement correctly
  • Easier to extend – AST makes adding new features straightforward
  • Secure by design – Random unfenced HTML like <b>...</b> shouldn’t be treated as such blindly

Djot vs Markdown: Quick Comparison

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).

Basic Usage

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);

Advanced Usage

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);

Markdown compatibility modes

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.

Soft break mode

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.

Significant Newlines Mode (Markdown-Like)

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.

Customization

Extension System

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'],
]));

Built-in Extensions

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.

Custom Rendering with Events

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');
}
});

Custom Inline Patterns

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.

Feature Restriction: Profiles

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());

SafeMode vs Profile

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()
);

Built-in Profiles

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

Understanding Restrictions

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"

Link Policies

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'])
);

Graceful Degradation

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);

Architecture

The package uses a clean separation of concerns:

  • BlockParser – Parses block-level elements (headings, lists, tables, code blocks, etc.)
  • InlineParser – Processes inline elements within blocks (emphasis, links, code spans)
  • HtmlRenderer – Converts the AST to HTML output

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.

WordPress Plugin: Djot Markup for WP

Want to use Djot in your WordPress site? There’s now a dedicated plugin that brings full Djot support to WordPress.

Features

  • Full Content Processing – Write entire posts in Djot syntax
  • Shortcode Support – Use [djot]...[/djot] for mixed content
  • Syntax Highlighting – Built-in highlight.js with 12+ themes
  • Profiles – Limit functionality per post/page/comment type, disable raw HTML
  • Admin Settings – Easy configuration via Settings → WP Djot
  • Markdown compatibility mode and soft-break settings if coming from MD

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:

  • ![Alt text](https://www.youtube.com/watch?v=aVx-zJPEF2c){video} renders videos from all WP supported sources right away, customize the attributes as always: {video width=300 height=200}
  • Import from HTML or markdown

You can extend the customizations also on your own.

IDE Support: IntelliJ Plugin

For developers using PhpStorm, IntelliJ IDEA, or other JetBrains IDEs, there’s now
an official Djot plugin available.

Features

  • Syntax Highlighting – Full TextMate grammar support for .djot files
  • Live Preview – Split-view editor with real-time rendered output
  • Theme Sync – Preview follows your IDE’s dark/light mode
  • Code Block Highlighting – Syntax highlighting within fenced code blocks
  • HTML Export – Save documents as rendered HTML files
  • Live Templates – Code snippets for common Djot patterns

The plugin requires JetBrains IDE 2024.1+ and Java 17+.

Enhancements

The library and the WP plugin already have some useful enhancements beyond the spec:

  • Full attribute support
  • Boolean Attribute Shorthand
  • Fenced Comment Blocks
  • Multiple Definition Terms and Definition Descriptions
  • Abbreviations definitions
  • Captions for Images, Tables, and Block Quotes
  • Markdown compatibility mode (Significant Newlines)

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.

Performance

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:

  • PHP processes ~2-3 MB/s of Djot content consistently
  • Performance scales linearly O(n) with document size
  • Safe mode and Profiles have negligible performance impact
  • Comparable to Python, ~2x slower than JavaScript reference implementation

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.

Comparison with PHP Markup Libraries

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:

  1. Simpler architecture – Parsedown uses a single-pass regex-based approach without building a full AST (Abstract Syntax Tree). It directly outputs HTML while parsing.
  2. No AST overhead – djot-php and CommonMark build a complete node tree first, then traverse it to render. This two-phase approach enables features (events, transforms, multiple output formats) but costs time.

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 Comparison

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

Features Unique to djot-php

  1. Smart Typography – Automatic curly quotes, em/en dashes, ellipsis
  2. Math Expressions – Inline $x^2$ and display $$ math
  3. Highlight/Insert/Delete{=highlight=}, {+insert+}, {-delete-}
  4. Super/SubscriptH~2~O, x^2^
  5. Divs with Classes::: warning ... :::
  6. Profile System – Restrict features per context (full/article/comment/minimal)
  7. Abbreviations – Auto-wrap terms with <abbr> tags
  8. Captions – For images, tables, and blockquotes
  9. Converters – Import from Markdown, BBCode, HTML

Importing and Migration

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:

  • HtmlToDjot
  • MarkdownToDjot
  • BbcodeToDjot

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.

What’s Next?

The library is actively maintained with plans for:

  • Additional renderers (convert Djot back for interoperability)
  • More converters
  • More markup supported (not contradicting the specs)
  • Maybe some framework specific plugins or integrations

Contributions welcome!

Some personal notes

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 ![](url) 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!

Links

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.

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

CakePHP File Management Solution 27 Nov 2025 5:20 AM (3 months ago)

Table of Contents

Meet the New FileStorage Plugin

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.

Enter: The Modern FileStorage Plugin

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.

What Makes It Different?

Store Anywhere (Seriously, Anywhere)

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.

Set It and Forget It

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.

Smart Architecture: One Table to Rule Them All

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.

Image Processing That Actually Works

“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 Details That Matter

Organized Storage, Your Way

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.

Validation Built In

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.

Built on Solid Foundations

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.

What Can You Build?

E-Commerce Done Right

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.

Social Platforms with Style

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.

Document Management That Scales

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.

Multi-Tenant Applications

Different storage backend per customer? Different path structures per tenant? Clean data separation? The plugin’s architecture makes it straightforward instead of scary.

Beyond the Basics: Extension Points

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.

Already Possible (Right Now)

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,
],
]);

Community-Driven Roadmap

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.

What This Means for You

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.

Getting Started (It’s Easier Than You Think)

Install It

composer require dereuromark/cakephp-file-storage

Want image processing too?

composer require php-collective/file-storage-image-processor

Configure It

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,
]);

Use It

// 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.

Why This Plugin Exists

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.

What You Get

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.

The Modern Stack: Version 4.0.0

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.

See It In Action

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

Not Just for CakePHP

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.

The Bottom Line

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.

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

TestHelper for your CakePHP apps 13 Nov 2025 11:48 PM (4 months ago)

Table of Contents

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.

The TestHelper plugin

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.

img

Cake Linter

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.

img

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

Browser test runner

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.

tests

Reverse URL Lookup

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.

img

Compare models, DB tables and fixture (factories)

You can also bake missing fixture factories per click from the backend.

Migration Re-Do tool

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.

Summary

The browser based addons can help for test-driven development, the CLI tools for asserting high quality.
Check out the plugin now.

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

CakePHP Tips Autumn 2025 28 Oct 2025 6:50 PM (4 months ago)

Table of Contents

Healthchecks / Uptime

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
Healthcheck Healthcheck CLI

You can define your own custom checks on top, or adjust/replace the default ones.

See docs for details.

Audit log your tables

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.

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

Fresh expert tips on CakePHP IDE and tooling best practices 9 Sep 2025 2:40 AM (6 months ago)

Table of Contents

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.

Running tools over modified subset

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

CI run

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.

Pimp up your PHPStan

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.

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

Improved Session authenticator for CakePHP 19 Jul 2025 11:25 AM (7 months ago)

Table of Contents

There is a new CakePHP session authenticator on the block.

Status quo

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.

toArray()/fromArray()

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.

PrimaryKeySession authenticator

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

  • The user edits his account data and we have to “persist changes back into session identity”
  • Any admin modifies the user’s data and they are now out of sync until a fresh login.

Custom finder

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.

Caching

If you use a custom auth finder that does quite a few extra joins and query, e.g.

  • User “contains” Groups, Permissions, Roles, ProfileData, …

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.

Summary

Why I recommend switching to PrimaryKeySession:

  • No more issues with deployments and changes to objects
  • Always up to date User (session) data
  • Much smaller session data storage size (across all users, especially if using DB session)

Things to look out for:

  • Invalidating the cache if you add this layer to ensure users don’t have to log out and log back in manually

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

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

CakePHP Tips Spring 2025 6 Jun 2025 6:45 AM (9 months ago)

Table of Contents

DB tools

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.

PHP Templating

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

Useful shortcut functions

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.

Complex search filters

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.

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?