Choosing DCA Over Custom Controllers for Contao Back-End Forms
Stop writing custom Symfony controllers for standard CRUD in Contao. The Data Container Array (DCA) generates list views, edit forms, and database schema from a single declarative PHP array. This post shows a complete job posting example and the exact verification steps.
12 Sept 2026, 05:07 UTC

The Problem: When to Stop Writing Admin Controllers
Contao developers often reach for custom Symfony controllers when they need a back-end editing interface. The framework makes it easy: create a route, build a form type, render a template. But every custom controller becomes maintenance surface—CSRF handling, permission checks, list views, pagination, export buttons. Contao's Data Container Array (DCA) already provides all of that declaratively.
The decision point is simple: if your editing need maps to a database table with standard CRUD operations, DCA eliminates the controller entirely. You describe the schema, fields, and palettes in a PHP array; Contao generates the list view, edit form, and database schema automatically.
How DCA Replaces a Controller
A DCA file lives in contao/dca/<table>.php and returns an associative array. The keys map directly to back-end behavior:
- config — table name, data container type (
Table), list/sort modes, SQL definition - fields — each column's
inputType,evalconstraints, and callbacks - palettes — field groupings for the edit view
- list — sorting, labeling, and operations (edit, copy, delete, show)
When you run contao:migrate (or the install tool), Contao reads the config.sql definition and creates or updates the table via Doctrine DBAL. The back end then renders the form from fields and palettes without any controller code.
Worked Example: Job Posting Table
Suppose you need a tl_job_posting table with title, location, description, and a published flag. One DCA file replaces a controller, form type, repository, and template:
<?php
// contao/dca/tl_job_posting.php
return [
'config' => [
'dataContainer' => 'Table',
'ctable' => ['tl_job_posting'],
'sql' => [
"CREATE TABLE `tl_job_posting` ("
. "`id` int(10) unsigned NOT NULL auto_increment,"
. "`title` varchar(255) NOT NULL default '',"
. "`location` varchar(255) NOT NULL default '',"
. "`description` text NULL,"
. "`published` char(1) NOT NULL default '',"
. "`tstamp` int(10) unsigned NOT NULL default '0',"
. "PRIMARY KEY (`id`)"
. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
],
],
'list' => [
'sorting' => ['mode' => 1, 'fields' => ['title'], 'flag' => 1],
'label' => ['fields' => ['title', 'location'], 'format' => '%s (%s)'],
'operations' => ['edit', 'copy', 'delete', 'show'],
],
'palettes' => [
'default' => '{title_legend},title,location;{description_legend},description;{publish_legend},published',
],
'fields' => [
'id' => ['sql' => "int(10) unsigned NOT NULL auto_increment"],
'title' => [
'inputType' => 'text',
'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'],
'sql' => "varchar(255) NOT NULL default ''"
],
'location' => [
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'sql' => "varchar(255) NOT NULL default ''"
],
'description' => [
'inputType' => 'textarea',
'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'],
'sql' => "text NULL"
],
'published' => [
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50 m12'],
'sql' => "char(1) NOT NULL default ''"
],
'tstamp' => ['sql' => "int(10) unsigned NOT NULL default '0'"],
],
];
After running php contao-console contao:migrate (requires CLI access to the project root), the table exists and the back end shows a fully functional list/edit interface under Content → Job Postings (assuming you register the navigation in contao/config/config.php).
Where DCA Falls Short
DCA handles declarative CRUD well, but it has limits:
- Complex workflows — multi-step wizards, conditional field dependencies beyond
eval.rgxp, or cross-table validation require callbacks that become hard to test. - Non-CRUD actions — bulk import, external API sync, or custom batch operations don't fit the list/edit/delete model.
- Version differences — Contao 4.13 LTS and 5.x have divergent callback signatures and palette syntax; a DCA written for 4.13 may throw
Undefined array keyerrors on 5.x.
For those cases, keep the DCA for the basic form and move heavy logic into a Symfony service invoked from a save_callback or a custom route. That preserves the generated UI while isolating complex code.
Verification Checklist
- Create the DCA file in
contao/dca/. - Run
php contao-console contao:migrate(or use the install tool) and confirm the table appears in the database. - Clear the Symfony cache:
php contao-console cache:clear --no-warmup. - Log into the Contao back end and verify the module appears in navigation (add to
contao/config/config.phpif missing). - Open the list view, create a record, and confirm all fields render with correct validation (mandatory, RTE, checkbox).
If the form appears but validation fails silently, check var/log/contao.log for eval key typos—DCA errors often surface as generic "Invalid field configuration" messages.
Closing: Default to DCA
Start every back-end editing requirement with a DCA file. Only extract a custom controller when the editing flow genuinely cannot be expressed as table fields, palettes, and callbacks. The migration path is clean: the DCA stays as the schema source, and the controller calls the same Doctrine repository. You keep the generated UI for 90% of the work and write code only for the 10% that matters.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.