Skip to main content

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​

GluoLaravelSymfonyCakePHPCodeIgniter
ShapeREST API + separate SPAone applicationone applicationone applicationone application
FrontendReact 19 + Vite, own containerReact 19 through Inertia, same appTwig, server-renderedPHP templates, server-renderedPHP views, server-rendered
Auth stateJWT in the browser, sent as Authorization: Bearersession cookiesession cookiesession cookiesession cookie
Reusable from another clientyes, it is an APIno, the endpoints return Inertia pagesno, the endpoints return HTMLno, the endpoints return HTMLno, the endpoints return HTML
ContainersAPI, frontend, MySQLapp, MySQLofficial: MySQL, mail catcher (PHP on the host). template: app, MySQL, mail catcherMySQL (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​

CommandQuestions asked
Gluocomposer create-project byjg/gluo gluo ^7.0 --stability=devnamespace, database, keep frontend, keep examples — or all from setup.json
Laravelcomposer create-project laravel/react-starter-kit laravel --stability=devwhich 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 webappnone; the choices are the packages you require
CakePHPcomposer create-project cakephp/app cakephpnone; it writes config/app_local.php and a security salt
CodeIgnitercomposer create-project codeigniter4/appstarter codeigniternone; shield:setup later asks for the sender e-mail and whether to migrate

Friction found while building:

  • Gluo — --stability=dev is required until 7.0 is tagged.
  • Laravel — the tagged starter kit is an old Laravel 12 one, so --stability=dev is 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 .env starts as a commented template (env), and the global CSRF filter ships turned off.

3. Login​

GluoLaravelSymfonyCakePHPCodeIgniter
Where it comes fromshipped with the startershipped with the starter kitgenerated by two maker commandsofficial Authentication pluginofficial Shield package, installed by shield:setup
MechanismPOST /login returns a JWTFortify session loginform_login authenticatorSession + Form authenticatorssession authenticator
Code you writenonenonenone~70 lines: the service, the middleware, the component, a UsersController with login and logout, the login template, password hashing in the entitynone
Also includedtoken refresh, password reset by e-mail code, rolesregistration, e-mail verification, password reset, 2FA, passkeyslogoutlogout (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)
Gluoany logged-in useradmins only — what codegen generates
Laravelany logged-in, verified userany logged-in, verified user
Symfonyany user with ROLE_USERany user with ROLE_USER
CakePHPany logged-in userany logged-in user
CodeIgniterany logged-in userany 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] on postProduct, putProduct and deleteProduct.
  • Make writing admin-only in Laravel: a ProductPolicy whose create, update and delete check the role, and $this->authorize(...) in the actions (or ->can(...) on the routes).
  • Make writing admin-only in Symfony: #[IsGranted('ROLE_ADMIN')] on new, edit and delete, or an access_control rule for those paths.
  • Make writing admin-only in CakePHP: the official Authorization plugin (cakephp/authorization) with a ProductPolicy, and $this->Authorization->authorize() in add, edit and delete.
  • Make writing admin-only in CodeIgniter: Shield's group filter on those routes ('filter' => 'group:admin'), or auth()->user()->inGroup('admin') in the actions.

Gluo is the only build where reading and writing have different permissions without extra work.

5. Migration​

GluoLaravelSymfonyCakePHPCodeIgniter
Written inSQLPHP schema builderSQL, generatedPHP table API (Phinx-style)PHP, with Forge
Who writes ityouyoumake:migration, by diffing entities against the databasebake migration, from the column list you typeyou
Rollbacka second SQL file you writedown() you write (generated for a new table)down() generatednone to write: change() is reversibledown() you write
Date columnsin the SQL you writetimestamps() + softDeletes()from the entity fields (official: generated by make:entity; template: from the bundle's traits)on the command line, like the other columnsin the Forge array you write
Runphp gluo --env=dev migrate:updateartisan migratedoctrine:migrations:migratebin/cake migrations migratephp 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​

GluoLaravelSymfonyCakePHPCodeIgniter
ORMMicroOrmEloquentDoctrine ORMCakePHP ORMModel + Query Builder
Pattern used hereActiveRecordActiveRecordData Mapper + RepositoryTable (repository) + EntityModel (table gateway)
Fields declared in the classyes, generatedno, read from the table at runtimeyes, generatedno, read from the table at runtime (listed in a generated docblock)no; $allowedFields lists the writable ones
Accessorsgeneratedmagic propertiesgeneratedmagic propertiesnone; rows are arrays ($product['name'])
File size118 lines, 1 edited by hand16 lines, 2 written by handofficial: 114 lines, ~15 by hand. template: 63 lines, 7 by handTable 71 + Entity 33 lines, ~10 edited by hand49 lines, 6 edited by hand
Type and length of each fieldyes: OA\Property(type, format, maxLength), generated from the columnno; they are in the migrationyes: ORM\Column(length, precision, scale), generatedthe length, as maxLength in the baked validationno; they are in the migration
Allowed range of a valueyes: OA\Property(maximum, minimum), by handno; it is in the FormRequestyes: Assert\Range, by handyes: greaterThanOrEqual / lessThanOrEqual, by handyes: $validationRules, by hand
Required fieldsyes: OA\Schema(required: [...]), generated from NOT NULLno; required in the FormRequestyes: Assert\NotBlank, by handyes: 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 requestsyes, and every save() (SchemaValidator)—yes (through the form)yes (patchEntity())yes (every insert() / update())
The same declaration is published to API clientsyes, as the OpenAPI schema—nonono

Model patterns each one supports​

Gluo (MicroOrm)Laravel (Eloquent)Symfony (Doctrine)CakePHP (ORM)CodeIgniter (Model)
ActiveRecordyes: use ActiveRecord on the model, then Product::getOrFail($id) (404 when missing), $product->save()yes, and it is the only built-in patternnono; entities do not save themselvesno; the model saves arrays or entities
Repositoryyes: 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 Eloquentyes, with Data Mapper: plain entities, the EntityManager persists them, one repository class per entity for queriesyes: the ProductsTable finds and saves Product entitiesa table gateway rather than a repository: one Model per table, with the queries and the rules
Choice per entityyes, both can be used in the same project————
Generatorboth: php gluo codegen:all writes the Repository pattern, --activerecord the othermake:model (ActiveRecord)make:entity writes the entity and its repositorybake model writes the table and the entity, from the databasemake:model (empty)
Layers in a generated CRUDActiveRecord: Controller -> Model. Repository: Controller -> Service -> Repository -> ModelController -> ModelController -> EntityManager / Repository -> EntityController -> Table -> EntityController -> Model
Files for one entityActiveRecord: 2. Repository: 4, plus 2 DI bindings1 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 EntityManager tracks changes and writes them on flush().

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​

GluoLaravelSymfony (official)Symfony (template)CakePHPCodeIgniter
Built inyesyestimestamps: lifecycle callbacks. Soft delete: nono — stof/doctrine-extensions-bundle (community)timestamps: yes (Timestamp behavior). Soft delete: no — muffin/trash (community)yes, both
Turned on bycodegen, when it sees the three columnstimestamps() + softDeletes() in the migration, SoftDeletes trait3 generated fields, 2 callbacks, 1 line in the delete action, a 21-line SQL filter, 1 YAML block2 traits + 1 attribute on the entity, 2 YAML blocks2 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 datesthe database (DEFAULT / ON UPDATE) and the traitEloquentyour callbacksthe bundle's listenerthe Timestamp behaviorthe model
Deleted rows hiddenautomaticallyautomaticallyby the filter you wroteonce the bundle's filter is enabledautomatically, by the Trash behavior (find('withTrashed') shows them)automatically (withDeleted() shows them)
Code you ownnonenoneall of itnonenonenone

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​

GluoLaravelSymfonyCakePHPCodeIgniter
Generatedget, list, create, update — workingmethod signatures onlyindex, new, show, edit, delete — workingindex, view, add, edit, delete — workingmethod signatures only
Written by handdeleteall six bodiesnothing (official build: 1 line changed in delete)nothingfive bodies (index, new, create, edit, update, delete; show and remove unused)
ReturnsJSONInertia page or redirectTwig page or redirectrendered template or redirectrendered view or redirect
Paging and ordering on listyes, generated (page, size, orderBy); the screen pages and sorts by nameno, the screen lists every productno, the screen lists every productyes, baked: $this->paginate(), columns sortable on the screenyes, 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​

GluoLaravelSymfonyCakePHPCodeIgniter
Declared onthe model properties and the endpoint's request body, as OpenAPI attributesa FormRequest classthe entity propertiesvalidationDefault() 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 actionthe form componentpatchEntity(), before save()every insert() / update()
Written bycodegen: types, required fields and lengths, from the table. You: value rangesyouyoubake: required fields and lengths, from the table. You: value rangesyou
Failure400 JSONredirect back with errors (422 for JSON requests)422, form re-rendered200, form re-rendered with the errorsredirect 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​

GluoLaravelSymfonyCakePHPCodeIgniter
Declared in#[OA\Get(path: ...)] on the methodroutes/web.php#[Route] on the methodnowhere for the CRUD: the skeleton's fallback routes map /products/edit/1 to ProductsController::edit(1)app/Config/Routes.php
Extra stepphp gluo openapi rebuilds public/docs/openapi.jsonnonenonenonenone
One line for the whole CRUDnoRoute::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​

GluoLaravelSymfonyCakePHPCodeIgniter
TechnologyReact page calling the APIReact page receiving props from the controllerTwig templatesPHP templates with the Form and Html helpersPHP views with the form helper
Generatednonoyes: list, show, new, edit, delete formyes: list (paged, sortable), view, add, editno
Written by hand1 page, 165 lines with paging and sorting, plus route and menu entry2 pages, 139 lines, plus type and menu entrynothingthe login template, the logout link; 3 date inputs removed from the baked forms2 views, 56 lines
StylingTailwind components shipped with the startershadcn/ui components shipped with the kitnone; plain HTMLCakePHP's default stylesheet, shipped with the skeletonnone; plain HTML (Shield's login screens use Bootstrap)
Typed links to the backendno, paths are stringsyes, Wayfinder generates themTwig path() by route name['controller' => ..., 'action' => ...] arrays, resolved by the routersite_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​

GluoLaravelSymfonyCakePHPCodeIgniter
Generated for the CRUD10 tests, passingan empty test class5 tests, 4 marked incomplete, with invalid data5 controller tests and 1 table test, all marked incomplete, plus a fixturenone
Written by hand here3 (soft delete; five invalid payloads, each on POST and PUT, sent to the server; an invalid save outside the API)77, replacing the generated ones8 (the 7, five invalid payloads on both add and edit, and a POST without the CSRF token) + 2 for the login, replacing the generated ones8 (the 7, five invalid payloads, and a POST without the CSRF token)
Stylein-process API callsHTTP feature tests + Inertia assertionsbrowser-kit crawler over the HTMLintegration tests through the whole middleware stackfeature tests through the filters
Checks the response shapeyes, against the OpenAPI contractonly what each test assertsonly what each test assertsonly what each test assertsonly what each test asserts
Databasea separate localtest schematesting database, refreshed per testapp_test databaseSQLite file (tmp/tests.sqlite), the skeleton's default; migrations run before the suiteSQLite 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.

GluoLaravelSymfonyCakePHPCodeIgniter
OpenAPI spec + Swagger UIyesnononono
Command line for framework tasks and your own commandsphp gluoartisanbin/consolebin/cakephp spark
Interactive shell (REPL)php gluo terminalartisan tinkernowith the REPL plugin (bin/cake console), not in the skeletonno
Check every command without running itphp gluo list --checknononono
Install a command as a system service (cron, systemd)php gluo installno: one crontab line for schedule:run, Supervisor for the workers, both set up by youno: the worker command, run under Supervisor or systemd set up by younono
Role-based accessyesgates/policies, not wiredroles, not wiredthe official Authorization plugin, not installedShield groups and permissions, not wired
Timestamps and soft deleteyesyesno (callbacks + your own filter, or a community bundle)timestamps yes; soft delete no (a community plugin)yes
HTTPS in the development setupfrontend only: self-signed certificate, generated at start, on :7443no; Sail serves plain HTTPofficial: no with php -S. template: yes, for the whole appnono
Docker setup from the framework itselfyes, full stackyes, full stack (Sail)services only (Flex); the full-stack template is a community projectnono
Password resetyesyesnonoby a login link sent by e-mail (Shield)
Registration, e-mail verification, 2FA, passkeysnoyesnonoregistration yes; e-mail activation and e-mail 2FA available, off by default; no passkeys
Profile screenyesyesnonono
Queue, scheduler, eventsnot in the core: message-queue-client for queues, php gluo install for cron jobs and workers, MicroOrm observers for changes to a tableyesyes (Messenger)events yes; queue as an official plugin (cakephp/queue), not installedevents yes; queue and scheduled tasks as official packages (codeigniter4/queue, codeigniter4/tasks), not installed
Debug toolbar / profilernonoyesyes (DebugKit)yes (debug toolbar)
Static analysis configuredPsalmPint (style), Larastannocode style (PHP_CodeSniffer, composer cs-check); phpstan.neon and psalm.xml shipped, the tools not installedno
Composer packages (prod + dev)56 + 25100 + 38official: 96 + 35. template: 98 + 3522 + 715 + 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; mounting cert.pem and key.pem at /certs replaces 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 server and php spark serve wrap 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 withcomposer create-project symfony/skeleton + composer require webappgit clone dunglas/symfony-docker, which runs the same skeleton inside the container
Packages outside symfony/*, Doctrine and Twignonestof/doctrine-extensions-bundle (Gedmo)
Dockerthe service file Flex writes: MySQL and a mail catchercommunity template: FrankenPHP app container, MySQL, mail catcher
Where PHP runson the host (symfony server:start, or php -S as used here)in the container
Web serverPHP built-in server (dev only)FrankenPHP / Caddy, also usable in production
HTTPS in developmentnoyes by default (turned off in this build)
Timestamps2 lifecycle callbacks, written by handbundle trait
Soft delete1 line in the controller + a 21-line SQL filter, written by handbundle trait + attribute
MySQL instead of the default PostgreSQL3 files4 files (also the Dockerfile)
Fixes needed to startnone1 (Mercure setting for the current FrankenPHP image)
Files owned by root on Linuxnoyes, needs a chown after generating files
Composer packages (prod + dev)96 + 3598 + 35
Hand-written lines for the feature~45~20
Tests8, the same file8, 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.

PurposeGluoLaravelSymfonyCakePHPCodeIgniterNotes
Application codesrc/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 migrationsdb/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 datadb/base.sql, insert in a migrationdatabase/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.
Configurationconfig/config/, .envconfig/, .env, .env.dev, .env.testconfig/app/Config/, .envGluo: 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 containerconfig/<env>/04-…07-*.phpbootstrap/, app/Providers/config/services.yaml, src/Kernel.phpsrc/Application.phpapp/Config/Services.phpGluo, 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.phpapp/Config/Routes.phpLaravel, 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 rootpublic/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.
Screensfrontend/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 templatestemplates/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.
Teststests/tests/tests/tests/tests/PHPUnit in all five.
Command linegluoartisanbin/bin/sparkphp gluo, php artisan, bin/console. Symfony's bin/ also has phpunit. bin/cake, php spark.
Project hooks for the framework tasksbuilder/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.
Dockerdocker/, docker-compose.ymlcompose.yamlcompose.yaml, compose.override.yamlcompose.yamlcompose.yamlGluo 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.
Dependenciesvendor/, frontend/node_modules/vendor/, node_modules/vendor/vendor/vendor/
Static analysis and stylepsalm.xmlphpstan.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 CakePHP bin/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 a class_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/ and frankenphp/). CodeIgniter puts almost everything under app/.

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.