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 and symfony.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​

GluoLaravelSymfony
ShapeREST API + separate SPAone applicationone application
FrontendReact 19 + Vite, own containerReact 19 through Inertia, same appTwig, server-rendered
Auth stateJWT in the browser, sent as Authorization: Bearersession cookiesession cookie
Reusable from another clientyes, it is an APIno, the endpoints return Inertia pagesno, the endpoints return HTML
ContainersAPI, frontend, MySQLapp, MySQLofficial: MySQL, mail catcher (PHP on the host). template: app, MySQL, mail catcher

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

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.

3. Login​

GluoLaravelSymfony
Where it comes fromshipped with the startershipped with the starter kitgenerated by two maker commands
MechanismPOST /login returns a JWTFortify session loginform_login authenticator
Code you writenonenonenone
Also includedtoken refresh, password reset by e-mail code, rolesregistration, e-mail verification, password reset, 2FA, passkeyslogout

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

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 }

Gluo is the only build where reading and writing have different permissions without extra work; the other two would add a policy (Laravel) or #[IsGranted] (Symfony).

5. Migration​

GluoLaravelSymfony
Written inSQLPHP schema builderSQL, generated
Who writes ityouyoumake:migration, by diffing entities against the database
Rollbacka second SQL file you writedown() you write (generated for a new table)down() generated
Date columnsin the SQL you writetimestamps() + softDeletes()from the entity fields (official: generated by make:entity; template: from the bundle's traits)
Run./gluo --env=dev migrate:updateartisan migratedoctrine:migrations: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');

The source of truth differs: the table in Gluo, the migration in Laravel, the entity class in Symfony.

6. Model​

GluoLaravelSymfony
ORMMicroOrmEloquentDoctrine ORM
Pattern used hereActiveRecordActiveRecordData Mapper + Repository
Fields declared in the classyes, generatedno, read from the table at runtimeyes, generated
Accessorsgeneratedmagic propertiesgenerated
File size118 lines, 1 edited by hand16 lines, 2 written by handofficial: 114 lines, ~15 by hand. template: 63 lines, 7 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), generated
Allowed range of a valueyes: OA\Property(minimum, maximum), by handno; it is in the FormRequestyes: Assert\Range, by hand
Required fieldsyes: OA\Schema(required: [...]), generated from NOT NULLno; required in the FormRequestyes: Assert\NotBlank, by hand
The same declaration validates requestsyes—yes (through the form)
The same declaration is published to API clientsyes, as the OpenAPI schema—no

Model patterns each one supports​

Gluo (MicroOrm)Laravel (Eloquent)Symfony (Doctrine)
ActiveRecordyes: use ActiveRecord on the model, then Product::get($id), $product->save()yes, and it is the only built-in patternno
Repositoryyes: Model + Repository + Service, bound in the DI confignot 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 queries
Choice per entityyes, both can be used in the same project——
Generatorboth: ./gluo codegen:all writes the Repository pattern, --activerecord the othermake:model (ActiveRecord)make:entity writes the entity and its repository
Layers in a generated CRUDActiveRecord: Controller -> Model. Repository: Controller -> Service -> Repository -> ModelController -> ModelController -> EntityManager / Repository -> Entity
Files for one entityActiveRecord: 2. Repository: 4, plus 2 DI bindings1 model (+ controller)2 (entity, repository) (+ 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 three where this is a choice, per entity, with a generator for both. Laravel gives you ActiveRecord and Symfony gives you Data Mapper; moving away from either 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
./gluo --env=dev codegen:repository --table=product --save
./gluo --env=dev codegen:service --table=product --save
./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
./gluo --env=dev codegen:model --table=product --activerecord --save
./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 taken from varchar(100), 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")]
class Product
{
use ActiveRecord;
use OaCreatedAt;
use OaUpdatedAt;
use OaDeletedAt;

#[OA\Property(type: "integer", format: "int32")]
#[FieldAttribute(primaryKey: true, fieldName: "id")]
protected int|null $id = null;

#[OA\Property(type: "string", format: "string", maxLength: 100)]
#[FieldAttribute(fieldName: "name")]
protected string|null $name = null;

#[OA\Property(type: "number", format: "double", minimum: 0, maximum: 99999999.99)]
#[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
}

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.

7. Timestamps and soft delete​

GluoLaravelSymfony (official)Symfony (template)
Built inyesyestimestamps: lifecycle callbacks. Soft delete: nono — stof/doctrine-extensions-bundle (community)
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 blocks
Who sets the datesthe database (DEFAULT / ON UPDATE) and the traitEloquentyour callbacksthe bundle's listener
Deleted rows hiddenautomaticallyautomaticallyby the filter you wroteonce the bundle's filter is enabled
Code you ownnonenoneall of itnone

Gluo and Laravel need nothing beyond naming the columns. 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​

GluoLaravelSymfony
Generatedget, list, create, update — workingmethod signatures onlyindex, new, show, edit, delete — working
Written by handdeleteall six bodiesnothing (official build: 1 line changed in delete)
ReturnsJSONInertia page or redirectTwig page or redirect
Paging and ordering on listyes, generated (page, size, orderBy)nono

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

The Gluo controller is the longest file of the three (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​

GluoLaravelSymfony
Declared onthe model properties and the endpoint's request body, as OpenAPI attributesa FormRequest classthe entity properties
Enforced by#[ValidateRequest]type-hinting the request in the actionthe form component
Written bycodegen: types, required fields and lengths, from the table. You: value rangesyouyou
Failure400 JSONredirect back with errors (422 for JSON requests)422, form re-rendered

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),
new OA\Property(property: "price", type: "number", format: "double", minimum: 0, maximum: 99999999.99),
]
)
)]

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.

All three end up enforcing the same rules: a name of at most 100 characters, and a price between 0 and 99999999.99, both required.

Gluo takes the types, the required fields and maxLength from the table when it generates the code. A range cannot be read from a column type, so minimum and maximum are added by hand, the same as min/max in Laravel and Assert\Range in Symfony. Tried against the running API, on both POST and PUT: a 101-character name, a negative price, a price above the range and a missing name are each rejected with 400, before anything reaches the database.

The same schema is what Swagger UI publishes, so the documented limits and the enforced ones cannot drift apart.

10. Routing​

GluoLaravelSymfony
Declared in#[OA\Get(path: ...)] on the methodroutes/web.php#[Route] on the method
Extra step./gluo openapi rebuilds public/docs/openapi.jsonnonenone
One line for the whole CRUDnoRoute::resource(...)no (generated per action)

In Gluo the route table is the generated openapi.json. Forgetting ./gluo openapi after changing an attribute gives a 404 with no error; composer test warns when the file is stale.

11. Screens​

GluoLaravelSymfony
TechnologyReact page calling the APIReact page receiving props from the controllerTwig templates
Generatednonoyes: list, show, new, edit, delete form
Written by hand1 page, 139 lines, plus route and menu entry2 pages, 139 lines, plus type and menu entrynothing
StylingTailwind components shipped with the startershadcn/ui components shipped with the kitnone; plain HTML
Typed links to the backendno, paths are stringsyes, Wayfinder generates themTwig path() by route name

Symfony is the only one where the screens cost nothing, and the only one where they are unstyled and have no menu or logout link.

12. Tests​

GluoLaravelSymfony
Generated for the CRUD9 tests, passingan empty test class5 tests, 4 marked incomplete, with invalid data
Written by hand here2 (soft delete; four invalid payloads)77, replacing the generated ones
Stylein-process API callsHTTP feature tests + Inertia assertionsbrowser-kit crawler over the HTML
Checks the response shapeyes, against the OpenAPI contractonly what each test assertsonly what each test asserts
Databasea separate localtest schematesting database, refreshed per testapp_test database

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

13. What comes in the box​

Beyond what this application needed.

GluoLaravelSymfony
OpenAPI spec + Swagger UIyesnono
Command line for framework tasks and your own commands./gluoartisanbin/console
Role-based accessyesgates/policies, not wiredroles, not wired
Timestamps and soft deleteyesyesno (callbacks + your own filter, or a community bundle)
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 app
Docker setup from the framework itselfyes, full stackyes, full stack (Sail)services only (Flex); the full-stack template is a community project
Password resetyesyesno
Registration, e-mail verification, 2FA, passkeysnoyesno
Profile screenyesyesno
Queue, scheduler, eventsnoyesyes (Messenger)
Debug toolbar / profilernonoyes
Static analysis configuredPsalmPint (style), Larastanno
Composer packages (prod + dev)56 + 25100 + 38official: 96 + 35. template: 98 + 35

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.

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.

PurposeGluoLaravelSymfonyNotes
Application codesrc/app/src/Gluo: Model, Controller (plus Repository, Service in that pattern). Laravel: Models, Http/Controllers, Http/Requests, Providers, Actions. Symfony: Entity, Controller, Repository, Form.
Database migrationsdb/database/migrations/migrations/All three version the schema, in three 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.
Seed and test datadb/base.sql, insert in a migrationdatabase/seeders/, database/factories/src/DataFixtures/Gluo seeds in SQL. Laravel and Symfony seed from PHP; Laravel's factories also feed the tests.
Configurationconfig/config/, .envconfig/, .env, .env.dev, .env.testGluo: 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.
Wiring the containerconfig/<env>/04-…07-*.phpbootstrap/, app/Providers/config/services.yaml, src/Kernel.phpAll three 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/.
Routes—routes/—Laravel is the only one with route files. Gluo and Symfony declare routes as attributes on the controller; Gluo then compiles them into public/docs/openapi.json.
Web rootpublic/public/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/.
Screensfrontend/resources/templates/, assets/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.
Other templatestemplates/resources/views/templates/Gluo: e-mail templates and the terminal preload. Laravel: the single Blade page that hosts the React app.
Teststests/tests/tests/PHPUnit in all three.
Command linegluoartisanbin/./gluo, php artisan, bin/console. Symfony's bin/ also has phpunit.
Project hooks for the framework tasksbuilder/app/Console/, routes/console.php—Gluo's builder/Scripts.php is where the built-in commands are customised; your own commands are listed in config/commands.php. Laravel and Symfony register commands as classes.
Cache, logs, sessions—storage/, bootstrap/cache/var/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.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/.
Translations——translations/Created empty by Symfony. Laravel publishes lang/ only on request.
Dependenciesvendor/, frontend/node_modules/vendor/, node_modules/vendor/
Static analysis and stylepsalm.xmlphpstan.neon, pint.json—
Instructions for AI coding agents.claude/—AGENTS.md, CLAUDE.mdGluo 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 migrations cannot be read as SQL at all, and Symfony's are SQL you did not write.
  • Where the routes live. Only Laravel has a place to look them up as a list. In Gluo the list is the generated OpenAPI file (and Swagger UI); in Symfony it is bin/console debug:router.
  • Who controls autowiring. All three inject by type hint. In Laravel and Symfony it is on for everything; 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.
  • How separate the frontend is. Gluo's frontend/ can be deleted, or deployed elsewhere, without touching the API. Laravel's and Symfony's screens are part of the application.
  • How many folders you start with. Counting folders at the first level, without dependencies and hidden ones: Gluo 9, Laravel 9, Symfony official 10, Symfony template 12 (two of those are the template's own docs/ and frankenphp/).

In one paragraph each​

Gluo generated the most working 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 — 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.