CakePHP
CakePHP 5.4 + its ORM + the official Authentication plugin, with the screens generated by
bake. CakePHP ships no Docker setup, so MySQL runs from a hand-written compose file and
PHP runs on the host. Soft delete comes from a community plugin, muffin/trash.
Requirements: PHP >= 8.2 with pdo_mysql and intl, Composer, Docker (for MySQL only).
1. Create the project
composer create-project cakephp/app cakephp
cd cakephp
The skeleton includes the Migrations plugin and bake. composer install creates
config/app_local.php from config/app_local.example.php and writes a security salt.
2. MySQL in Docker, and the datasource
compose.yaml, written by hand:
services:
database:
image: mysql:8.4
environment:
MYSQL_DATABASE: app
MYSQL_ROOT_PASSWORD: password
ports:
- "3310:3306"
config/app_local.example.php (and so app_local.php), Datasources.default:
'host' => '127.0.0.1',
'port' => 3310,
'username' => 'root',
'password' => 'password',
'database' => 'app',
The test suite does not use MySQL: CakePHP's test datasource defaults to SQLite
(tmp/tests.sqlite), and this build keeps that default.
3. Migrations from the command line
bin/cake bake migration CreateProducts 'name:string[100]' 'price:decimal[10,2]' 'created_at:datetime' 'updated_at:datetime' 'deleted_at:datetime?'
bin/cake bake migration CreateUsers 'email:string[255]:unique' 'password:string[255]' 'created:datetime' 'modified:datetime'
bin/cake migrations migrate
Both migrations are complete as generated — the column list on the command line is the
migration. They are PHP classes with a reversible change():
$table = $this->table('products');
$table->addColumn('name', 'string', ['default' => null, 'limit' => 100, 'null' => false]);
$table->addColumn('price', 'decimal', ['default' => null, 'null' => false, 'precision' => 10, 'scale' => 2]);
// + created_at, updated_at, deleted_at (nullable)
$table->create();
4. Bake the CRUD from the table
bin/cake bake all products --everything
bake reads the existing table and writes the ProductsTable and Product entity,
a working ProductsController (paginated index, view, add, edit, delete), the four
templates, a fixture and the tests. The validation comes from the columns:
$validator
->scalar('name')
->maxLength('name', 100)
->requirePresence('name', 'create')
->notEmptyString('name');
--everything also bakes every other table, so it wrote a users CRUD as well; this build
keeps only the Users table and entity, and deletes the users controller, its templates
and its test.
5. Hand edits
Plugins:
composer require cakephp/authentication muffin/trash
bin/cake plugin load Authentication
bin/cake plugin load Muffin/Trash
Timestamps and soft delete — src/Model/Table/ProductsTable.php. The Timestamp
behavior is CakePHP's own, configured for these column names; soft delete is the
community muffin/trash behavior:
$this->addBehavior('Timestamp', [
'events' => ['Model.beforeSave' => ['created_at' => 'new', 'updated_at' => 'always']],
]);
$this->addBehavior('Muffin/Trash.Trash', ['field' => 'deleted_at']);
bake had made the three date columns required form fields (they are NOT NULL in the
migration); remove those validators, and their inputs from templates/Products/add.php
and edit.php. In src/Model/Entity/Product.php, keep only name and price in
$_accessible, so a form cannot set the dates.
Required on update too — bake writes requirePresence('name', 'create'), so an
update that leaves a field out is accepted. Remove the 'create' argument from both
requirePresence() calls, so the fields are required on update as well, as in the other
builds.
Price range — in the same validationDefault():
$validator
->decimal('price')
->requirePresence('price')
->notEmptyString('price')
->greaterThanOrEqual('price', 0)
->lessThanOrEqual('price', 99999999.99);
Login — the plugin's setup, by hand:
-
src/Application.phpimplementsAuthenticationServiceProviderInterface, addsAuthenticationMiddlewareafter the body parser, and builds the service:$service = new AuthenticationService(['unauthenticatedRedirect' => Router::url('/users/login'),'queryParam' => 'redirect',]);$service->loadAuthenticator('Authentication.Session');$service->loadAuthenticator('Authentication.Form', ['fields' => ['username' => 'email', 'password' => 'password'],'loginUrl' => Router::url('/users/login'),]); -
src/Controller/AppController.php—$this->loadComponent('Authentication.Authentication'), so every action requires a logged-in user -
src/Controller/UsersController.php—login()andlogout(), withloginallowed for guests -
templates/Users/login.php— the form -
src/Model/Entity/User.php—_setPassword()hashes the password with the plugin'sDefaultPasswordHasher -
src/Controller/PagesController.php—displayallowed for guests, so the skeleton's static pages stay public -
templates/layout/default.php— a Logout link when a user is logged in -
config/routes.php—/goes to the products list
Seeds:
bin/cake bake seed Users
bin/cake bake seed Products
They are generated empty; fill them with the admin user (the password hashed in the seed, since a seed writes rows directly) and five products.
Tests — tests/TestCase/Controller/ProductsControllerTest.php and
UsersControllerTest.php. bake generated them with markTestIncomplete() bodies, and the
table tests too; the table tests are removed, and the fixture's sample product is made
not deleted.
Code style — bake's own output does not pass CakePHP's code-style check (unused
imports). composer cs-fix fixes it.
6. Migrate, seed, test and run
composer install
docker compose up -d --wait
bin/cake migrations migrate
bin/cake seeds run
vendor/bin/phpunit
composer cs-check
bin/cake server -H 0.0.0.0 -p 8084
Open http://localhost:8084, log in with [email protected] / !P4ssw0rdstr!.
Stop with Ctrl-C and docker compose down (add -v to drop the database volume).
Screens and routes
| Method | Path | Result |
|---|---|---|
| GET/POST | /users/login | login screen |
| GET | /products | list, paginated, sortable by column |
| GET | /products/view/{id} | one product |
| GET/POST | /products/add | new product |
| GET/POST | /products/edit/{id} | edit product |
| POST | /products/delete/{id} | soft delete (sets deleted_at) |
Guests are redirected to /users/login on every product route.
What was generated vs written
| Generated by commands | project, both migrations, the Product and User table and entity classes, a working controller, 4 templates with styling, fixtures, empty seeds, test skeletons |
| Written by hand | the Docker file, the datasource, 2 behaviors, the price range, presence on update, the login setup (service, middleware, component, 2 actions, 1 template, password hashing), 1 route, the seeds, the tests |
CakePHP is the closest to Gluo in direction: bake starts from the table and generates working code, including the screens. What it does not give you is soft delete (a community plugin) and a login (an official plugin, wired by hand).