Feature by feature
How each part of the application is done, with the code that is actually in this repository. For the commands that produced it, see the step-by-step guides: gluo.md, laravel.md, symfony-official.md, symfony.md, cakephp.md and codeigniter.md.
Symfony is built twice. symfony-official/ uses official packages only;
symfony/ uses the community Docker template and a community soft-delete bundle. They
share the login, the generated CRUD, the validation, the routes, the screens and the
tests, so the Symfony column below describes both. Where they differ — creating the
project, timestamps and soft delete, Docker — the text says so, and
section 14 puts the two side by side.
- 1. Architecture
- 2. Creating the project
- 3. Login
- 4. Protecting the screens
- 5. Migration
- 6. Model
- 7. Timestamps and soft delete
- 8. CRUD controller
- 9. Validation
- 10. Routing
- 11. Screens
- 12. Tests
- 13. What comes in the box
- 14. The two Symfony builds
- 15. Project layout
1. Architecture
| Gluo | Laravel | Symfony | CakePHP | CodeIgniter | |
|---|---|---|---|---|---|
| Shape | REST API + separate SPA | one application | one application | one application | one application |
| Frontend | React 19 + Vite, own container | React 19 through Inertia, same app | Twig, server-rendered | PHP templates, server-rendered | PHP views, server-rendered |
| Auth state | JWT in the browser, sent as Authorization: Bearer | session cookie | session cookie | session cookie | session cookie |
| Reusable from another client | yes, it is an API | no, the endpoints return Inertia pages | no, the endpoints return HTML | no, the endpoints return HTML | no, the endpoints return HTML |
| Containers | API, frontend, MySQL | app, MySQL | official: MySQL, mail catcher (PHP on the host). template: app, MySQL, mail catcher | MySQL (PHP on the host) | MySQL (PHP on the host) |
Gluo is the only one where the backend is a documented API that another client (mobile app, another service) could call as is.
2. Creating the project
| Command | Questions asked | |
|---|---|---|
| Gluo | composer create-project byjg/gluo gluo ^7.0 --stability=dev | namespace, database, keep frontend, keep examples — or all from setup.json |
| Laravel | composer create-project laravel/react-starter-kit laravel --stability=dev | which auth features to keep |
| Symfony (official) | composer create-project symfony/skeleton, composer require webapp (or symfony new --webapp) | whether Flex should write Docker configuration |
| Symfony (template) | git clone dunglas/symfony-docker, docker compose up, composer require webapp | none; the choices are the packages you require |
| CakePHP | composer create-project cakephp/app cakephp | none; it writes config/app_local.php and a security salt |
| CodeIgniter | composer create-project codeigniter4/appstarter codeigniter | none; shield:setup later asks for the sender e-mail and whether to migrate |
Friction found while building:
- Gluo —
--stability=devis required until 7.0 is tagged. - Laravel — the tagged starter kit is an old Laravel 12 one, so
--stability=devis required here too; the installer needs Node next to PHP. - Symfony (official) — no problem creating the project. The database service Flex writes is PostgreSQL, so MySQL is a three-file manual switch.
- Symfony (template) — the Docker template did not start with the current FrankenPHP image until one Mercure setting was changed, and it also defaults to PostgreSQL: a four-file manual switch.
- CakePHP — no problem creating the project, but no Docker setup: a compose file for MySQL is written by hand, and the datasource points to it.
- CodeIgniter — no problem creating the project, and no Docker setup either. The
.envstarts as a commented template (env), and the global CSRF filter ships turned off.
3. Login
| Gluo | Laravel | Symfony | CakePHP | CodeIgniter | |
|---|---|---|---|---|---|
| Where it comes from | shipped with the starter | shipped with the starter kit | generated by two maker commands | official Authentication plugin | official Shield package, installed by shield:setup |
| Mechanism | POST /login returns a JWT | Fortify session login | form_login authenticator | Session + Form authenticators | session authenticator |
| Code you write | none | none | none | ~70 lines: the service, the middleware, the component, a UsersController with login and logout, the login template, password hashing in the entity | none |
| Also included | token refresh, password reset by e-mail code, roles | registration, e-mail verification, password reset, 2FA, passkeys | logout | logout (written by you) | registration, login by e-mail link, groups and permissions, remember-me, logout |
Gluo — src/Controller/LoginController.php is a thin class over the core; the route,
the request schema and the validation are attributes:
#[OA\Post(path: "/login", tags: ["Login"])]
#[OA\RequestBody(...)] // username + password
#[ValidateRequest]
public function post(HttpResponse $response, HttpRequest $request): void
{
parent::post($response, $request);
}
Laravel — there is no login controller in the project; Fortify owns the routes and
the project only says which page renders the form (app/Providers/FortifyServiceProvider.php):
Fortify::loginView(fn (Request $request) => Inertia::render('auth/login', [...]));
Symfony — make:security:form-login writes a controller that only shows the form;
the firewall does the authentication (config/packages/security.yaml):
firewalls:
main:
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
default_target_path: app_product_index
logout:
path: app_logout
CakePHP — the plugin does the authentication; the application builds the service in
src/Application.php and writes the two actions:
$service->loadAuthenticator('Authentication.Session');
$service->loadAuthenticator('Authentication.Form', [
'fields' => ['username' => 'email', 'password' => 'password'],
'loginUrl' => Router::url('/users/login'),
]);
// src/Controller/UsersController.php
public function login(): ?Response
{
$result = $this->Authentication->getResult();
if ($result?->isValid()) {
return $this->redirect($this->Authentication->getLoginRedirect() ?? '/products');
}
// ...
}
CodeIgniter — shield:setup adds one line to app/Config/Routes.php, which brings the
login, registration, e-mail link and logout routes, controllers and views:
service('auth')->routes($routes);
4. Protecting the screens
Gluo — per endpoint, as an attribute, with roles available out of the box:
#[RequireAuthenticated] // any valid token: get, list
#[RequireRole(User::ROLE_ADMIN)] // admin only: create, update, delete
On the SPA side the route sits inside <ProtectedLayout>, which redirects to /login
when there is no valid token.
Laravel — per route group, in routes/web.php:
Route::middleware(['auth', 'verified'])->group(function () {
Route::resource('products', ProductController::class)->except('show');
});
Symfony — by URL pattern, in config/packages/security.yaml:
access_control:
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER }
CakePHP — every action, through the component loaded in AppController; a
controller lists the actions a guest may open:
// src/Controller/AppController.php
$this->loadComponent('Authentication.Authentication');
// src/Controller/UsersController.php
$this->Authentication->allowUnauthenticated(['login']);
CodeIgniter — by route, with Shield's session filter:
$routes->presenter('products', ['controller' => 'ProductController', 'except' => 'show,remove', 'filter' => 'session']);
The builds keep their framework's default, so they do not enforce the same rule:
| Read (get, list) | Write (create, update, delete) | |
|---|---|---|
| Gluo | any logged-in user | admins only — what codegen generates |
| Laravel | any logged-in, verified user | any logged-in, verified user |
| Symfony | any user with ROLE_USER | any user with ROLE_USER |
| CakePHP | any logged-in user | any logged-in user |
| CodeIgniter | any logged-in user | any logged-in user |
The seed user is an admin, so the difference does not show when you try the builds. To make them match, change one side:
- Let every logged-in user write in Gluo: replace
#[RequireRole(User::ROLE_ADMIN)]with#[RequireAuthenticated]onpostProduct,putProductanddeleteProduct. - Make writing admin-only in Laravel: a
ProductPolicywhosecreate,updateanddeletecheck the role, and$this->authorize(...)in the actions (or->can(...)on the routes). - Make writing admin-only in Symfony:
#[IsGranted('ROLE_ADMIN')]onnew,editanddelete, or anaccess_controlrule for those paths. - Make writing admin-only in CakePHP: the official Authorization plugin
(
cakephp/authorization) with aProductPolicy, and$this->Authorization->authorize()inadd,editanddelete. - Make writing admin-only in CodeIgniter: Shield's group filter on those routes
(
'filter' => 'group:admin'), orauth()->user()->inGroup('admin')in the actions.
Gluo is the only build where reading and writing have different permissions without extra work.
5. Migration
| Gluo | Laravel | Symfony | CakePHP | CodeIgniter | |
|---|---|---|---|---|---|
| Written in | SQL | PHP schema builder | SQL, generated | PHP table API (Phinx-style) | PHP, with Forge |
| Who writes it | you | you | make:migration, by diffing entities against the database | bake migration, from the column list you type | you |
| Rollback | a second SQL file you write | down() you write (generated for a new table) | down() generated | none to write: change() is reversible | down() you write |
| Date columns | in the SQL you write | timestamps() + softDeletes() | from the entity fields (official: generated by make:entity; template: from the bundle's traits) | on the command line, like the other columns | in the Forge array you write |
| Run | php gluo --env=dev migrate:update | artisan migrate | doctrine:migrations:migrate | bin/cake migrations migrate | php spark migrate |
Gluo — db/migrations/up/00001-create-table-product.sql:
create table product
(
id int auto_increment not null primary key,
name varchar(100) not null,
price decimal(10,2) not null,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL
) ENGINE=InnoDB;
Laravel — database/migrations/*_create_products_table.php:
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name', 100);
$table->decimal('price', 10, 2);
$table->timestamps();
$table->softDeletes();
});
Symfony — nothing is written; the generated migration contains:
$this->addSql('CREATE TABLE product (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(100) NOT NULL,
price NUMERIC(10, 2) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL,
deleted_at DATETIME DEFAULT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
CakePHP — config/Migrations/*_CreateProducts.php, written by bake migration from
'name:string[100]' 'price:decimal[10,2]' 'created_at:datetime' 'updated_at:datetime' 'deleted_at:datetime?':
$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]);
$table->addColumn('created_at', 'datetime', ['default' => null, 'null' => false]);
$table->addColumn('updated_at', 'datetime', ['default' => null, 'null' => false]);
$table->addColumn('deleted_at', 'datetime', ['default' => null, 'null' => true]);
$table->create();
CodeIgniter — app/Database/Migrations/*_ProductMigration.php, generated empty by
make:scaffold and filled in:
$this->forge->addField([
'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'name' => ['type' => 'VARCHAR', 'constraint' => 100],
'price' => ['type' => 'DECIMAL', 'constraint' => '10,2'],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addPrimaryKey('id');
$this->forge->createTable('products');
The source of truth differs: the table in Gluo, the migration in Laravel and CodeIgniter, the entity class in Symfony. CakePHP sits in between: the command line writes the migration, and from then on bake reads the table.
6. Model
| Gluo | Laravel | Symfony | CakePHP | CodeIgniter | |
|---|---|---|---|---|---|
| ORM | MicroOrm | Eloquent | Doctrine ORM | CakePHP ORM | Model + Query Builder |
| Pattern used here | ActiveRecord | ActiveRecord | Data Mapper + Repository | Table (repository) + Entity | Model (table gateway) |
| Fields declared in the class | yes, generated | no, read from the table at runtime | yes, generated | no, read from the table at runtime (listed in a generated docblock) | no; $allowedFields lists the writable ones |
| Accessors | generated | magic properties | generated | magic properties | none; rows are arrays ($product['name']) |
| File size | 118 lines, 1 edited by hand | 16 lines, 2 written by hand | official: 114 lines, ~15 by hand. template: 63 lines, 7 by hand | Table 71 + Entity 33 lines, ~10 edited by hand | 49 lines, 6 edited by hand |
| Type and length of each field | yes: OA\Property(type, format, maxLength), generated from the column | no; they are in the migration | yes: ORM\Column(length, precision, scale), generated | the length, as maxLength in the baked validation | no; they are in the migration |
| Allowed range of a value | yes: OA\Property(maximum, minimum), by hand | no; it is in the FormRequest | yes: Assert\Range, by hand | yes: greaterThanOrEqual / lessThanOrEqual, by hand | yes: $validationRules, by hand |
| Required fields | yes: OA\Schema(required: [...]), generated from NOT NULL | no; required in the FormRequest | yes: Assert\NotBlank, by hand | yes: requirePresence + notEmptyString, baked from NOT NULL (presence on create only; extended to updates by hand) | yes: required in $validationRules, by hand |
| The same declaration validates requests | yes, and every save() (SchemaValidator) | — | yes (through the form) | yes (patchEntity()) | yes (every insert() / update()) |
| The same declaration is published to API clients | yes, as the OpenAPI schema | — | no | no | no |
Model patterns each one supports
| Gluo (MicroOrm) | Laravel (Eloquent) | Symfony (Doctrine) | CakePHP (ORM) | CodeIgniter (Model) | |
|---|---|---|---|---|---|
| ActiveRecord | yes: use ActiveRecord on the model, then Product::getOrFail($id) (404 when missing), $product->save() | yes, and it is the only built-in pattern | no | no; entities do not save themselves | no; the model saves arrays or entities |
| Repository | yes: Model + Repository + Service, bound in the DI config. The model's attributes build a Data Mapper, which both patterns use (Choosing a Pattern) | not built in; you write the layer yourself on top of Eloquent | yes, with Data Mapper: plain entities, the EntityManager persists them, one repository class per entity for queries | yes: the ProductsTable finds and saves Product entities | a table gateway rather than a repository: one Model per table, with the queries and the rules |
| Choice per entity | yes, both can be used in the same project | — | — | — | — |
| Generator | both: php gluo codegen:all writes the Repository pattern, --activerecord the other | make:model (ActiveRecord) | make:entity writes the entity and its repository | bake model writes the table and the entity, from the database | make:model (empty) |
| Layers in a generated CRUD | ActiveRecord: Controller -> Model. Repository: Controller -> Service -> Repository -> Model | Controller -> Model | Controller -> EntityManager / Repository -> Entity | Controller -> Table -> Entity | Controller -> Model |
| Files for one entity | ActiveRecord: 2. Repository: 4, plus 2 DI bindings | 1 model (+ controller) | 2 (entity, repository) (+ controller) | 2 (table, entity) (+ controller) | 1 model (+ controller) |
- ActiveRecord — the object saves and loads itself. Least code, and the model is tied to the database, which is fine for plain CRUD.
- Repository — the model is data only; a separate class talks to the database, and in Gluo a service holds the business rules between the controller and the repository. More files, easier to test and to grow.
- Data Mapper — Doctrine's version of the same separation: the entity knows nothing
about persistence, and the
EntityManagertracks changes and writes them onflush().
Gluo is the only one of the five where this is a choice, per entity, with a generator for both. Laravel gives you ActiveRecord, Symfony gives you Data Mapper, CakePHP a table class per table with entities, and CodeIgniter a model per table; moving away from any of them means writing the other pattern by hand.
In Gluo the choice is made when the code is generated, not in a configuration setting: the two patterns produce a different model and a different controller. The endpoints and the generated test are the same in both, so the API does not change. Switching an entity later is a regeneration:
# ActiveRecord -> Repository: add the two layers and replace the controller
php gluo --env=dev codegen:repository --table=product --save
php gluo --env=dev codegen:service --table=product --save
php gluo --env=dev codegen:controller --table=product --save
# Repository -> ActiveRecord: replace the model and the controller,
# then delete the repository, the service and their DI bindings
php gluo --env=dev codegen:model --table=product --activerecord --save
php gluo --env=dev codegen:controller --table=product --activerecord --save
--save overwrites the controller, so hand edits to it (here, the delete endpoint and
the price range) have to be applied again. Without --save the code is printed instead of
written, which is a way to look at the other pattern before deciding.
This comparison uses ActiveRecord for Gluo because it is the smallest version of the same feature.
The model in each project
Gluo — src/Model/Product.php. Each field carries two attributes: FieldAttribute
maps it to the column, and OA\Property declares its type, length and range. Everything
is as codegen wrote it, including maxLength: 100 and minLength: 1 taken from
varchar(100) not null, the key marked readOnly (the database generates it), and the
validator that checks the model against this schema on every save, except the price range,
which was added by hand:
#[OA\Schema(required: ["id", "name", "price"], type: "object", xml: new OA\Xml(name: "Product"))]
#[TableAttribute("product", validator: SchemaValidator::class)]
class Product
{
use ActiveRecord;
use OaCreatedAt;
use OaUpdatedAt;
use OaDeletedAt;
#[OA\Property(type: "integer", format: "int32", readOnly: true)]
#[FieldAttribute(primaryKey: true, fieldName: "id")]
protected int|null $id = null;
#[OA\Property(type: "string", format: "string", maxLength: 100, minLength: 1)]
#[FieldAttribute(fieldName: "name")]
protected string|null $name = null;
#[OA\Property(type: "number", format: "double", maximum: 99999999.99, minimum: 0)]
#[FieldAttribute(fieldName: "price")]
protected float|null $price = null;
// + getters and setters
}
Laravel — app/Models/Product.php, complete:
#[Fillable(['name', 'price'])]
class Product extends Model
{
use HasFactory, SoftDeletes;
}
Symfony — src/Entity/Product.php, template build (the official build declares the
three date fields itself instead of the two traits; see section 7):
#[ORM\Entity(repositoryClass: ProductRepository::class)]
#[Gedmo\SoftDeleteable]
class Product
{
use TimestampableEntity;
use SoftDeleteableEntity;
#[ORM\Column(length: 100)]
#[Assert\NotBlank]
#[Assert\Length(max: 100)]
private ?string $name = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2)]
#[Assert\NotBlank]
#[Assert\Range(min: 0, max: 99999999.99)]
private ?string $price = null;
// + id, getters and setters
}
CakePHP — src/Model/Table/ProductsTable.php, as bake wrote it from the table, plus
the two behaviors and the price range added by hand (the validation is shown in
9. Validation):
public function initialize(array $config): void
{
parent::initialize($config);
$this->setTable('products');
$this->setDisplayField('name');
$this->setPrimaryKey('id');
$this->addBehavior('Timestamp', [
'events' => ['Model.beforeSave' => ['created_at' => 'new', 'updated_at' => 'always']],
]);
$this->addBehavior('Muffin/Trash.Trash', ['field' => 'deleted_at']);
}
and src/Model/Entity/Product.php, which only says which fields a form may set:
protected array $_accessible = [
'name' => true,
'price' => true,
];
CodeIgniter — app/Models/ProductModel.php, generated with every setting at its
default; these are the lines changed:
protected $useSoftDeletes = true;
protected $allowedFields = ['name', 'price'];
protected $useTimestamps = true;
protected $validationRules = [
'name' => 'required|max_length[100]',
'price' => 'required|decimal|greater_than_equal_to[0]|less_than_equal_to[99999999.99]',
];
The Gluo model and the Symfony entity carry the same kind of information: the type and
length of each field, which fields are required, and the range a value may take. In
both, the length comes from the generator and the range is written by hand. They differ
in what the declaration is used for. Symfony's ORM\Column drives the SQL of the
migration and Assert\* drives form validation. Gluo's OA\Property drives request
validation and is also the schema published in the OpenAPI document; the column's
precision and scale stay in the SQL migration. Laravel's model declares none of it: the
types are in the migration and the rules in the FormRequest. CakePHP and CodeIgniter
keep the rules next to the model — in the table class and in the model — and the types
in the migration.
7. Timestamps and soft delete
| Gluo | Laravel | Symfony (official) | Symfony (template) | CakePHP | CodeIgniter | |
|---|---|---|---|---|---|---|
| Built in | yes | yes | timestamps: lifecycle callbacks. Soft delete: no | no — stof/doctrine-extensions-bundle (community) | timestamps: yes (Timestamp behavior). Soft delete: no — muffin/trash (community) | yes, both |
| Turned on by | codegen, when it sees the three columns | timestamps() + softDeletes() in the migration, SoftDeletes trait | 3 generated fields, 2 callbacks, 1 line in the delete action, a 21-line SQL filter, 1 YAML block | 2 traits + 1 attribute on the entity, 2 YAML blocks | 2 behaviors in the table class (the Timestamp one told the column names), 1 plugin loaded | $useTimestamps = true and $useSoftDeletes = true on the model |
| Who sets the dates | the database (DEFAULT / ON UPDATE) and the trait | Eloquent | your callbacks | the bundle's listener | the Timestamp behavior | the model |
| Deleted rows hidden | automatically | automatically | by the filter you wrote | once the bundle's filter is enabled | automatically, by the Trash behavior (find('withTrashed') shows them) | automatically (withDeleted() shows them) |
| Code you own | none | none | all of it | none | none | none |
Gluo, Laravel and CodeIgniter need nothing beyond naming the columns (and, in CodeIgniter, two settings). CakePHP has timestamps but no soft delete of its own; this build uses the community plugin most CakePHP projects use, in the same role Gedmo plays for Symfony. Symfony and Doctrine have no soft delete, so there are two ways to get it.
Official packages only — symfony-official/. The dates use Doctrine lifecycle
callbacks, and deleting becomes "set the date" plus a Doctrine SQL filter:
#[ORM\HasLifecycleCallbacks]
class Product
{
#[ORM\PrePersist]
public function onCreate(): void
{
$this->createdAt = $this->updatedAt = new \DateTimeImmutable();
}
#[ORM\PreUpdate]
public function onUpdate(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
}
// src/Controller/ProductController.php, in the generated delete()
$product->setDeletedAt(new \DateTimeImmutable());
// src/Doctrine/SoftDeleteFilter.php
class SoftDeleteFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
{
if (!$targetEntity->hasField('deletedAt')) {
return '';
}
return sprintf('%s.%s IS NULL', $targetTableAlias, $targetEntity->getColumnName('deletedAt'));
}
}
Community bundle — symfony/. Two traits and an attribute on the entity (shown in
6. Model) and this configuration:
# config/packages/stof_doctrine_extensions.yaml
stof_doctrine_extensions:
orm:
default:
timestampable: true
softdeleteable: true
# config/packages/doctrine.yaml, under doctrine.orm
filters:
softdeleteable:
class: Gedmo\SoftDeleteable\Filter\SoftDeleteableFilter
enabled: true
The bundle is less code and is what most Symfony projects use; the official-only route adds about 35 lines you maintain yourself. Both pass the same soft-delete test.
8. CRUD controller
| Gluo | Laravel | Symfony | CakePHP | CodeIgniter | |
|---|---|---|---|---|---|
| Generated | get, list, create, update — working | method signatures only | index, new, show, edit, delete — working | index, view, add, edit, delete — working | method signatures only |
| Written by hand | delete | all six bodies | nothing (official build: 1 line changed in delete) | nothing | five bodies (index, new, create, edit, update, delete; show and remove unused) |
| Returns | JSON | Inertia page or redirect | Twig page or redirect | rendered template or redirect | rendered view or redirect |
| Paging and ordering on list | yes, generated (page, size, orderBy); the screen pages and sorts by name | no, the screen lists every product | no, the screen lists every product | yes, baked: $this->paginate(), columns sortable on the screen | yes, one call: paginate(20) and $pager->links(); sorted by name |
Gluo — src/Controller/ProductController.php (OpenAPI attributes omitted):
#[RequireAuthenticated]
public function listProduct(HttpResponse $response, HttpRequest $request): void
{
$page = (int)($request->queryString('page') ?? 0);
$size = (int)($request->queryString('size') ?? 50);
$query = Query::getInstance()
->table(Product::tableName())
->orderBy(OrderBy::fromQueryString($request->queryString('orderBy'), Product::class))
->limit($page * $size, $size);
$response->write(Product::query($query));
}
#[RequireRole(User::ROLE_ADMIN)]
#[ValidateRequest]
public function postProduct(HttpResponse $response, HttpRequest $request): void
{
$model = Product::new(ValidateRequest::getPayload());
$model->save();
$response->write(["id" => $model->getId()]);
}
Laravel — app/Http/Controllers/ProductController.php:
public function index(): Response
{
return Inertia::render('products/index', [
'products' => Product::latest('id')->get(),
]);
}
public function store(ProductRequest $request): RedirectResponse
{
Product::create($request->validated());
return to_route('products.index');
}
Symfony — src/Controller/ProductController.php, as make:crud wrote it:
#[Route('/new', name: 'app_product_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
$product = new Product();
$form = $this->createForm(ProductType::class, $product);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($product);
$entityManager->flush();
return $this->redirectToRoute('app_product_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('product/new.html.twig', ['product' => $product, 'form' => $form]);
}
A Gluo action does not return anything: it receives the request and the response, and
writes to the response ($response->write($model)), which serializes it. There is no
return response()->json(...) or return $this->render(...).
CakePHP — src/Controller/ProductsController.php, as bake wrote it:
public function add()
{
$product = $this->Products->newEmptyEntity();
if ($this->request->is('post')) {
$product = $this->Products->patchEntity($product, $this->request->getData());
if ($this->Products->save($product)) {
$this->Flash->success(__('The product has been saved.'));
return $this->redirect(['action' => 'index']);
}
$this->Flash->error(__('The product could not be saved. Please, try again.'));
}
$this->set(compact('product'));
}
CodeIgniter — app/Controllers/ProductController.php, a ResourcePresenter whose
bodies are written by hand:
public function index()
{
return view('products/index', [
'products' => $this->model->orderBy('name')->paginate(20),
'pager' => $this->model->pager,
]);
}
public function create(): RedirectResponse
{
if (! $this->model->insert($this->request->getPost(['name', 'price']))) {
return redirect()->back()->withInput()->with('errors', $this->model->errors());
}
return redirect()->to('/products');
}
The Gluo controller is the longest file of the five (about 300 lines), because every endpoint carries its OpenAPI description. That description is not decoration: it is the route, the request validation and the published documentation at once.
The generated list endpoint also sorts: ?orderBy=price desc,name. The value comes from
the client, so it is checked against the fields of the model before it is used, and
anything else — an unknown field, or an attempt at SQL — is answered with 400. There is
no generated filter parameter.
9. Validation
| Gluo | Laravel | Symfony | CakePHP | CodeIgniter | |
|---|---|---|---|---|---|
| Declared on | the model properties and the endpoint's request body, as OpenAPI attributes | a FormRequest class | the entity properties | validationDefault() in the table class | $validationRules on the model |
| Enforced by | #[ValidateRequest] on the request, and SchemaValidator on every save() | type-hinting the request in the action | the form component | patchEntity(), before save() | every insert() / update() |
| Written by | codegen: types, required fields and lengths, from the table. You: value ranges | you | you | bake: required fields and lengths, from the table. You: value ranges | you |
| Failure | 400 JSON | redirect back with errors (422 for JSON requests) | 422, form re-rendered | 200, form re-rendered with the errors | redirect back with the errors and the old input |
Gluo — generated on postProduct:
#[OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ["name", "price"],
properties: [
new OA\Property(property: "name", type: "string", format: "string", maxLength: 100, minLength: 1),
new OA\Property(property: "price", type: "number", format: "double", maximum: 99999999.99, minimum: 0),
]
)
)]
Everything in it is generated except the price range. putProduct validates against the
model's schema (#/components/schemas/Product), shown in 6. Model, so the
same limits are declared there.
Laravel — app/Http/Requests/ProductRequest.php:
return [
'name' => ['required', 'string', 'max:100'],
'price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
];
Symfony — the #[Assert\...] attributes shown in 6. Model.
CakePHP — validationDefault() in src/Model/Table/ProductsTable.php. The name
rules and the presence of price are as bake read them from the table, except that bake
requires the fields only when creating (requirePresence('name', 'create')), so an
update could leave one out; the second argument is removed by hand, as is the range:
$validator
->scalar('name')
->maxLength('name', 100)
->requirePresence('name')
->notEmptyString('name');
$validator
->decimal('price')
->requirePresence('price')
->notEmptyString('price')
->greaterThanOrEqual('price', 0)
->lessThanOrEqual('price', 99999999.99);
CodeIgniter — the $validationRules shown in 6. Model.
All five end up enforcing the same rules, on create and on update: a name that is not empty and has at most 100 characters, and a price between 0 and 99999999.99, both required.
Gluo takes the types, the required fields, maxLength and minLength from the table when
it generates the code: name is varchar(100) not null with no default, so it gets
maxLength: 100, minLength: 1, and an empty name is refused like a missing one, as
Laravel's required, Symfony's NotBlank, CakePHP's notEmptyString and CodeIgniter's
required do. A range cannot be read from a column type, so minimum and maximum are
added by hand, the same as min/max in Laravel, Assert\Range in Symfony, the two
comparison rules in CakePHP and greater_than_equal_to/less_than_equal_to in
CodeIgniter. CakePHP's bake is the only other generator that reads the length and the
NOT NULL from the table.
The test sends each invalid payload to the server, on both POST and PUT: a 101-character
name, an empty name, a negative price, a price above the range and a missing name are
each answered with 400, and the table is unchanged. The requests are sent without the
OpenAPI spec on the client side; otherwise the test client itself would refuse them and
the server would never be exercised.
The model is checked against the same schema again when it is saved, whoever saves it: a
command or a queue consumer that writes a product with a negative price gets the same 400,
and nothing is written (testAnInvalidProductIsRejectedWhenSavedOutsideTheApi). That is the
place CakePHP and CodeIgniter keep their rules too, in the table class and in the model;
Laravel's FormRequest and Symfony's form only check what comes through a request. In all
five, a write that bypasses the model (a query builder update, raw SQL) is not checked.
The same schema is what Swagger UI publishes, so the documented limits and the enforced ones cannot drift apart.
10. Routing
| Gluo | Laravel | Symfony | CakePHP | CodeIgniter | |
|---|---|---|---|---|---|
| Declared in | #[OA\Get(path: ...)] on the method | routes/web.php | #[Route] on the method | nowhere for the CRUD: the skeleton's fallback routes map /products/edit/1 to ProductsController::edit(1) | app/Config/Routes.php |
| Extra step | php gluo openapi rebuilds public/docs/openapi.json | none | none | none | none |
| One line for the whole CRUD | no | Route::resource(...) | no (generated per action) | none needed (conventions) | $routes->presenter(...) |
In Gluo the route table is the generated openapi.json. Forgetting php gluo openapi
after changing an attribute gives a 404 with no error; php gluo test (and
php gluo openapi:check on its own) warns when the file is stale.
11. Screens
| Gluo | Laravel | Symfony | CakePHP | CodeIgniter | |
|---|---|---|---|---|---|
| Technology | React page calling the API | React page receiving props from the controller | Twig templates | PHP templates with the Form and Html helpers | PHP views with the form helper |
| Generated | no | no | yes: list, show, new, edit, delete form | yes: list (paged, sortable), view, add, edit | no |
| Written by hand | 1 page, 165 lines with paging and sorting, plus route and menu entry | 2 pages, 139 lines, plus type and menu entry | nothing | the login template, the logout link; 3 date inputs removed from the baked forms | 2 views, 56 lines |
| Styling | Tailwind components shipped with the starter | shadcn/ui components shipped with the kit | none; plain HTML | CakePHP's default stylesheet, shipped with the skeleton | none; plain HTML (Shield's login screens use Bootstrap) |
| Typed links to the backend | no, paths are strings | yes, Wayfinder generates them | Twig path() by route name | ['controller' => ..., 'action' => ...] arrays, resolved by the router | site_url() paths, or url_to() by route name |
Symfony and CakePHP are the two where the screens cost nothing. CakePHP's come styled, with paging and sortable columns; Symfony's are unstyled and have no menu or logout link. CodeIgniter's are written by hand, like Laravel's and Gluo's.
12. Tests
| Gluo | Laravel | Symfony | CakePHP | CodeIgniter | |
|---|---|---|---|---|---|
| Generated for the CRUD | 10 tests, passing | an empty test class | 5 tests, 4 marked incomplete, with invalid data | 5 controller tests and 1 table test, all marked incomplete, plus a fixture | none |
| Written by hand here | 3 (soft delete; five invalid payloads, each on POST and PUT, sent to the server; an invalid save outside the API) | 7 | 7, replacing the generated ones | 8 (the 7, five invalid payloads on both add and edit, and a POST without the CSRF token) + 2 for the login, replacing the generated ones | 8 (the 7, five invalid payloads, and a POST without the CSRF token) |
| Style | in-process API calls | HTTP feature tests + Inertia assertions | browser-kit crawler over the HTML | integration tests through the whole middleware stack | feature tests through the filters |
| Checks the response shape | yes, against the OpenAPI contract | only what each test asserts | only what each test asserts | only what each test asserts | only what each test asserts |
| Database | a separate localtest schema | testing database, refreshed per test | app_test database | SQLite file (tmp/tests.sqlite), the skeleton's default; migrations run before the suite | SQLite in memory, the starter's default; migrations run per test class |
Gluo:
$request = new FakeApiRequester();
$request
->withPsr7Request($this->getPsr7Request())
->withMethod('POST')
->withPath("/product")
->withRequestBody(json_encode($this->getSampleData(true)))
->expectStatus(200)
->withRequestHeader(["Authorization" => "Bearer " . $result['token']]);
$body = $this->sendRequest($request);
Laravel:
$this->actingAs(User::factory()->create());
$this->post(route('products.store'), ['name' => 'Keyboard', 'price' => 49.9])
->assertRedirect(route('products.index'));
$this->assertDatabaseHas('products', ['name' => 'Keyboard', 'price' => 49.90]);
Symfony:
$this->login();
$this->client->request('GET', '/product/new');
$this->client->submitForm('Save', ['product[name]' => 'Keyboard', 'product[price]' => '49.90']);
self::assertResponseRedirects('/product');
CakePHP:
$this->session(['Auth' => $this->fetchTable('Users')->get(1)]);
$this->enableCsrfToken();
$this->post('/products/add', ['name' => 'Mouse', 'price' => '19.90']);
$this->assertRedirect(['controller' => 'Products', 'action' => 'index']);
CodeIgniter:
$result = $this->actingAs($this->user)
->post('products/create', ['name' => 'Keyboard', 'price' => '49.90', csrf_token() => csrf_hash()]);
$result->assertRedirectTo(site_url('products'));
$this->seeInDatabase('products', ['name' => 'Keyboard', 'price' => '49.90']);
CakePHP and CodeIgniter test against SQLite by default, so the tests do not need the MySQL container; the other three use a MySQL test database.
13. What comes in the box
Beyond what this application needed.
| Gluo | Laravel | Symfony | CakePHP | CodeIgniter | |
|---|---|---|---|---|---|
| OpenAPI spec + Swagger UI | yes | no | no | no | no |
| Command line for framework tasks and your own commands | php gluo | artisan | bin/console | bin/cake | php spark |
| Interactive shell (REPL) | php gluo terminal | artisan tinker | no | with the REPL plugin (bin/cake console), not in the skeleton | no |
| Check every command without running it | php gluo list --check | no | no | no | no |
| Install a command as a system service (cron, systemd) | php gluo install | no: one crontab line for schedule:run, Supervisor for the workers, both set up by you | no: the worker command, run under Supervisor or systemd set up by you | no | no |
| Role-based access | yes | gates/policies, not wired | roles, not wired | the official Authorization plugin, not installed | Shield groups and permissions, not wired |
| Timestamps and soft delete | yes | yes | no (callbacks + your own filter, or a community bundle) | timestamps yes; soft delete no (a community plugin) | yes |
| HTTPS in the development setup | frontend only: self-signed certificate, generated at start, on :7443 | no; Sail serves plain HTTP | official: no with php -S. template: yes, for the whole app | no | no |
| Docker setup from the framework itself | yes, full stack | yes, full stack (Sail) | services only (Flex); the full-stack template is a community project | no | no |
| Password reset | yes | yes | no | no | by a login link sent by e-mail (Shield) |
| Registration, e-mail verification, 2FA, passkeys | no | yes | no | no | registration yes; e-mail activation and e-mail 2FA available, off by default; no passkeys |
| Profile screen | yes | yes | no | no | no |
| Queue, scheduler, events | not in the core: message-queue-client for queues, php gluo install for cron jobs and workers, MicroOrm observers for changes to a table | yes | yes (Messenger) | events yes; queue as an official plugin (cakephp/queue), not installed | events yes; queue and scheduled tasks as official packages (codeigniter4/queue, codeigniter4/tasks), not installed |
| Debug toolbar / profiler | no | no | yes | yes (DebugKit) | yes (debug toolbar) |
| Static analysis configured | Psalm | Pint (style), Larastan | no | code style (PHP_CodeSniffer, composer cs-check); phpstan.neon and psalm.xml shipped, the tools not installed | no |
| Composer packages (prod + dev) | 56 + 25 | 100 + 38 | official: 96 + 35. template: 98 + 35 | 22 + 71 | 5 + 30 |
HTTPS in development
-
Gluo — the frontend container serves HTTPS as well as HTTP. Its web server,
byjg/static-httpserver, creates a self-signed certificate when it starts and listens on 8443, published as https://localhost:7443; mountingcert.pemandkey.pemat/certsreplaces it with a real one. The API container is plain HTTP on :8080, and that is the address the frontend calls. -
Laravel — Sail serves plain HTTP. This build runs on http://localhost:8081 and nothing was added for HTTPS.
-
Symfony, template — FrankenPHP (Caddy) serves the whole application over HTTPS by default, with certificates from its own local certificate authority. This build turns that off (
SERVER_NAME=:80) to keep plain HTTP on :8082 next to the other projects. -
Symfony, official — PHP's built-in server, used here, has no HTTPS. Symfony's documentation describes TLS support in
symfony server:start, which was not used in this build. -
CakePHP and CodeIgniter —
bin/cake serverandphp spark servewrap PHP's built-in server, which has no HTTPS.
So two of the setups come with HTTPS without any work: Gluo for the frontend, and the Symfony Docker template for the whole application.
14. The two Symfony builds
Laravel's starter kit, Sail and Eloquent's soft delete are all first-party, and Gluo ships its own Docker files and soft-delete trait, so one build of each is "official" by construction. Symfony is the one where following the documentation leads outside the framework, so it is built both ways.
symfony-official/ | symfony/ | |
|---|---|---|
| Created with | composer create-project symfony/skeleton + composer require webapp | git clone dunglas/symfony-docker, which runs the same skeleton inside the container |
Packages outside symfony/*, Doctrine and Twig | none | stof/doctrine-extensions-bundle (Gedmo) |
| Docker | the service file Flex writes: MySQL and a mail catcher | community template: FrankenPHP app container, MySQL, mail catcher |
| Where PHP runs | on the host (symfony server:start, or php -S as used here) | in the container |
| Web server | PHP built-in server (dev only) | FrankenPHP / Caddy, also usable in production |
| HTTPS in development | no | yes by default (turned off in this build) |
| Timestamps | 2 lifecycle callbacks, written by hand | bundle trait |
| Soft delete | 1 line in the controller + a 21-line SQL filter, written by hand | bundle trait + attribute |
| MySQL instead of the default PostgreSQL | 3 files | 4 files (also the Dockerfile) |
| Fixes needed to start | none | 1 (Mercure setting for the current FrankenPHP image) |
| Files owned by root on Linux | no | yes, needs a chown after generating files |
| Composer packages (prod + dev) | 96 + 35 | 98 + 35 |
| Hand-written lines for the feature | ~45 | ~20 |
| Tests | 8, the same file | 8, the same file |
What is identical: the login (make:user, make:security:form-login), the entity
fields for name and price, make:crud and everything it generates, the validation
attributes, access control, the fixtures and the tests.
What the choice costs: the official-only build is simpler to start and has nothing to fix, but you write and maintain the soft delete yourself and you need PHP on the machine. The template build gives a complete, production-capable container and less application code, in exchange for depending on two community projects, one of which did not start without a change.
15. Project layout
The first-level folders and files of each project, lined up by what they are for. The Symfony column is the same for both builds unless a cell says otherwise.
| Purpose | Gluo | Laravel | Symfony | CakePHP | CodeIgniter | Notes |
|---|---|---|---|---|---|---|
| Application code | src/ | app/ | src/ | src/ | app/ | Gluo: Model, Controller (plus Repository, Service in that pattern). Laravel: Models, Http/Controllers, Http/Requests, Providers, Actions. Symfony: Entity, Controller, Repository, Form. CakePHP: Model/Table, Model/Entity, Controller, View. CodeIgniter: Controllers, Models, Views, Config, Database, Filters. |
| Database migrations | db/ | database/migrations/ | migrations/ | config/Migrations/ | app/Database/Migrations/ | All five version the schema, in four different languages. Gluo: plain .sql files, one in up/ and one in down/, plus base.sql for the starting schema. Laravel: PHP classes that call the schema builder ($table->string(...)), no SQL. Symfony: PHP classes whose body is SQL strings, generated by comparing the entities with the database. CakePHP: PHP classes with a reversible change(), written by bake migration from the columns on the command line. CodeIgniter: PHP classes using Forge, up() and down() written by hand. |
| Seed and test data | db/base.sql, insert in a migration | database/seeders/, database/factories/ | src/DataFixtures/ | config/Seeds/, tests/Fixture/ | app/Database/Seeds/ | Gluo seeds in SQL. Laravel and Symfony seed from PHP; Laravel's factories also feed the tests. CakePHP and CodeIgniter seed from PHP classes; CakePHP's fixtures feed the tests. |
| Configuration | config/ | config/, .env | config/, .env, .env.dev, .env.test | config/ | app/Config/, .env | Gluo: one folder per environment (dev, test, staging, prod), where every *.php and every *.env file is loaded (the starter ships numbered PHP files and a credentials.env); environments inherit from each other. config/.env is an optional, git-ignored file for overrides on your own machine, with config/.env.sample as its template. Everything stays inside config/. Laravel: one PHP file per subject, values from a single .env at the project root. Symfony: YAML, one file per bundle under config/packages/, values from .env files at the project root, one per environment. CakePHP: app.php, plus a git-ignored app_local.php for the machine's values. CodeIgniter: one PHP class per subject in app/Config/, overridden by .env. |
| Wiring the container | config/<env>/04-…07-*.php | bootstrap/, app/Providers/ | config/services.yaml, src/Kernel.php | src/Application.php | app/Config/Services.php | Gluo, Laravel and Symfony build a class by reading the type hints of its constructor. They differ in who decides what the container may build. Gluo: the developer. Autowire::rule() autowires every class of a namespace you name (the starter does it for the controllers, in 07-controllers.php), and a repository or service is bound one by one with withInjectedConstructor(), where you also choose singleton or per-use. A class that no rule or binding covers is not built. Laravel: the container builds any concrete class on request, with no registration. Symfony: services.yaml registers and autowires everything under src/. CakePHP: Application::services(), with autowiring off until a ReflectionContainer is added. CodeIgniter: no container; Config\Services has a static factory per service. |
| Routes | — | routes/ | — | config/routes.php | app/Config/Routes.php | Laravel, CakePHP and CodeIgniter have route files. Gluo and Symfony declare routes as attributes on the controller; Gluo then compiles them into public/docs/openapi.json. CakePHP's fallback routes map /products/edit/1 to the controller by convention, so its CRUD needed no route. CodeIgniter declares it in one presenter() line. |
| Web root | public/ | public/ | public/ | webroot/ | public/ | The front controller is app.php in Gluo and index.php in the others. Gluo's also holds the generated OpenAPI spec and Swagger UI; Laravel's receives the built frontend in public/build/. CakePHP's web root is webroot/, with its default stylesheet. |
| Screens | frontend/ | resources/ | templates/, assets/ | templates/ | app/Views/ | Gluo: a separate package with its own package.json, src/ and Docker image. Laravel: React pages in resources/js/, with package.json, vite.config.ts and node_modules/ at the project root. Symfony: Twig templates, with JavaScript in assets/ served through importmap.php, without Node. CakePHP: PHP templates per controller, baked. CodeIgniter: PHP views, written by hand. |
| Other templates | templates/ | resources/views/ | templates/ | templates/layout/, email/, element/ | — | Gluo: e-mail templates and the terminal preload. Laravel: the single Blade page that hosts the React app. CakePHP: the layout, e-mail and reusable elements. CodeIgniter's Shield screens live in its package. |
| Tests | tests/ | tests/ | tests/ | tests/ | tests/ | PHPUnit in all five. |
| Command line | gluo | artisan | bin/ | bin/ | spark | php gluo, php artisan, bin/console. Symfony's bin/ also has phpunit. bin/cake, php spark. |
| Project hooks for the framework tasks | builder/ | app/Console/, routes/console.php | — | src/Console/ | — | Gluo's builder/Scripts.php is where the built-in commands are customised; your own commands are declared in src/Command/Commands.php. Laravel and Symfony register commands as classes. CakePHP's Installer.php runs after composer install. |
| Cache, logs, sessions | — | storage/, bootstrap/cache/ | var/ | tmp/, logs/ | writable/ | Gluo keeps no runtime folder in the project: the configuration cache goes to the system temp directory. |
| Docker | docker/, docker-compose.yml | compose.yaml | compose.yaml, compose.override.yaml | compose.yaml | compose.yaml | Gluo ships its Dockerfiles in the project. Laravel's images live inside vendor/laravel/sail. Symfony official: service definitions only. Symfony template adds Dockerfile, frankenphp/, compose.prod.yaml and .devcontainer/. CakePHP and CodeIgniter ship none: both files are written by hand, for MySQL only. |
| Translations | — | — | translations/ | resources/ | app/Language/ | Created empty by Symfony. Laravel publishes lang/ only on request. |
| Dependencies | vendor/, frontend/node_modules/ | vendor/, node_modules/ | vendor/ | vendor/ | vendor/ | |
| Static analysis and style | psalm.xml | phpstan.neon, pint.json | — | phpcs.xml, phpstan.neon, psalm.xml | — | CakePHP configures PHP_CodeSniffer; its PHPStan and Psalm files come without the tools. |
| Instructions for AI coding agents | .claude/ | — | AGENTS.md, CLAUDE.md | — | — | Gluo ships a development skill; Symfony ships conventions. |
Things that stand out:
- Where the schema lives. Gluo's
db/is the only folder a database administrator could read and run without PHP. Laravel's, CakePHP's and CodeIgniter's migrations cannot be read as SQL at all, and Symfony's are SQL you did not write. - Where the routes live. Laravel and CodeIgniter keep them in a file you can read as
a list. In Gluo the list is the generated OpenAPI file (and Swagger UI); in Symfony it
is
bin/console debug:router, and in CakePHPbin/cake routes, since its CRUD is reached through the fallback convention rather than declared routes. - Who controls autowiring. Gluo, Laravel and Symfony inject by type hint. In Laravel
and Symfony it is on for everything; in CakePHP it is off until you add a
ReflectionContainer(this build injects nothing); CodeIgniter has no container. In Gluo it is on where you turn it on, by a pattern rule or a binding, so the config files list exactly what the container can build. It is also a trade-off: an explicit binding is a plain lookup, while a class found by a pattern rule costs aclass_exists()check and a pattern match each time it is resolved. - How separate the frontend is. Gluo's
frontend/can be deleted, or deployed elsewhere, without touching the API. The other four frameworks' screens are part of the application. - How many folders you start with. Counting folders at the first level, without
dependencies and hidden ones: CodeIgniter 4, Gluo 9, Laravel 9, Symfony official 10,
CakePHP 10, Symfony template 12 (two of those are the template's own
docs/andfrankenphp/). CodeIgniter puts almost everything underapp/.
In one paragraph each
Gluo generated a working, documented and tested backend from the least input: one SQL file became a model, four endpoints, their documentation and passing tests. What it left to write was the delete endpoint, the price range and the whole screen, and it asks you to remember one extra command after touching a route.
Laravel generated the least for the feature itself, together with CodeIgniter — every body and every screen was written by hand — but what you write is short, and the starter kit ships by far the most complete authentication and UI.
Symfony generated the whole CRUD including the screens and the SQL, at the price of the most setup: access control by hand, unstyled pages, placeholder tests, and no soft delete of its own — either hand-written with official packages only, or from a community bundle on a community Docker template that needed fixing.
CakePHP is the closest to Gluo in direction: bake read the table and wrote a working controller, the validation from the columns and four styled screens with paging and sortable columns, so the feature itself cost almost nothing. The setup is where the work went: no Docker of its own, a login wired by hand from the official plugin, and soft delete from a community plugin; its test files were placeholders to rewrite.
CodeIgniter is the smallest: 5 production packages, a project that is almost all one
app/ folder, and timestamps, soft delete, validation and pagination as settings of the
base model. Its generator writes only empty classes, so the migration, the controller
bodies and the screens are written by hand; Shield brings the login in two commands.
There is no Docker setup either.