asconsulting / contao-zyppy-classes
A module for the Contao CMS to allow for easy selection of pre-configured CSS classes.
Package info
github.com/asconsulting/contao-zyppy-classes
Type:contao-bundle
pkg:composer/asconsulting/contao-zyppy-classes
Requires
- php: ^8.1
- contao/core-bundle: >=5.3
Requires (Dev)
- contao/manager-plugin: ^2.0
- contao/test-case: >=5.3
- phpunit/phpunit: ^9.6 || ^10.5 || ^11.0 || ^12.0
Suggests
None
Provides
None
Conflicts
- contao/manager-plugin: <2.0 || >=3.0
Replaces
None
README
A Contao 5 bundle that replaces the free-text "CSS class" box with a controlled vocabulary. The site owner defines the class names once in Design > CSS classes; editors pick from dropdowns and checkbox lists instead of typing.
Pages, articles, content elements, front end modules, forms and form fields each gain a Class Settings legend with up to three pickers: one exclusive class, any number of common classes for that kind of element, and any number of site-wide global classes. The chosen classes reach the markup on every rendering path Contao has, without re-rendering anything.
Requirements
- PHP 8.1 or newer, the floor Contao 5.3 itself declares. A newer Contao raises the PHP requirement with it.
- Contao 5.3 or newer, with no upper bound. The Zyppy Suite supports the LTS releases and is tested against the current non-LTS release as well: the test suite passes on core-bundle 5.3.0, 5.7.13 and 6.0.2 (last run 2026-10-07).
Installation
With Contao Manager
The package is on Packagist, so no Composer repository has to be configured.
- Open Contao Manager (
/contao-manager.phar.phpon your site) and log in. - On the Packages tab, type
contao-zyppy-classesinto the search field and click Add onasconsulting/contao-zyppy-classesin the results. - Click Apply changes. The Manager runs Composer and installs the package and its dependencies.
- When Composer finishes, the Manager checks for pending database changes and offers to run the migration. Confirm it. If the prompt does not appear, open Maintenance and run Database migration there.
With Composer on the command line
composer require asconsulting/contao-zyppy-classes:^5.0 vendor/bin/contao-console contao:migrate
What the migration does
It creates the tl_zyppy_class table and adds three columns to each of the six
element tables.
Usage
Defining the vocabulary
Open Design > CSS classes. One row of tl_zyppy_class is one selectable
class. A row carries a scope, a type, a token and a label, plus
a sorting number:
| Column | Values |
|---|---|
scope |
global, or one of page, article, content, module, form, formField |
type |
exclusive (the editor picks at most one) or common (any number) |
token |
the CSS class that ends up in the markup |
label |
what the editor sees; falls back to the token |
sorting |
order within a scope and type; lower first |
The global scope holds the one common list every element offers. There is no
global exclusive list - a row saved that way is simply never read.
Making a picker mandatory
Each of the 13 vocabularies has a companion *Required checkbox in
Settings that makes the matching picker mandatory (articleClassesRequired
and so on). Ticking it also drops the blank "-" choice from the exclusive
picker. A switch is ignored while its vocabulary is empty: "required, but
nothing to choose" would make the record unsavable with no visible cause.
What editors see
tl_article, tl_content, tl_form, tl_form_field, tl_module and
tl_page each gain three fields — exclusiveClass, commonClasses and
globalCommonClasses — grouped under a Class Settings legend placed before
the expert legend.
A vocabulary left empty takes its picker out of the palette entirely - label,
field and, once the last of the three has gone, the class legend as well. It is
also kept out of the editAll and overrideAll field choosers, which build
their lists from the table's fields rather than from the palette.
Where the classes end up
| Element | Written to |
|---|---|
| page | the <body> class |
| article, content element, front end module | the element's own class attribute |
form inserted with {{insert_form::x}} |
the form wrapper's class |
| form field | the widget's class |
The three fields are merged into one ordered, de-duplicated list and added alongside whatever the editor typed into the element's own CSS ID/class box; a class that is already there is not added twice.
Migration from legacy version
This package replaces the legacy package asconsulting/zyppy_class (namespace
ZyppyClass\). The old name does not resolve any more.
The four legacy Zyppy packages — page, classes, popup and search — have to be
upgraded together, in one composer update: old and new packages register
the same DCA fields and module types, so a site that briefly has both installed
double-registers. The walkthrough for the whole suite, including the order of
operations and a verification checklist, is UPGRADING.md in the
contao-zyppy-page repository. The part specific to this package:
- Element data is byte-identical; there is no data migration. The legacy
package declared
exclusiveClass,commonClassesandglobalCommonClasseson the same six tables with the same SQL. Nothing on the element side moves and nothing has to be re-picked. - What moves is the vocabulary. Until 2026-09-04 the 13 vocabularies were
keyValueWizardfields ontl_settings, which is aDC_Filecontainer: saving wrotesystem/config/localconfig.php, a filesystem write that fails on a read-only deployment.ImportSettingsVocabularyMigrationcopies them intotl_zyppy_classon the firstcontao:migrateafter upgrading, and never again - once the table holds a single row it stands down, so a class an editor deletes stays deleted. Only the vocabularies moved; the*Requiredswitches are stilltl_settingskeys. - Migrate first, touch nothing first.
localconfig.phpis the migration's only data source. Hand-cleaning the old keys out of it before migrating, or adding even one class in the new back end module before migrating, loses the vocabulary for good. After migrating, the old keys are inert and can be removed at leisure. - Verify the result, not the exit code. Check that
tl_zyppy_classholds roughly as many rows as you had tokens, and that all six scopes still show their pickers under Class Settings. - Templates: none, at either version. Nothing to audit.
- Records holding the literal
0token incommonClasses— a leftover of the old placeholder behaviour — render asclass="0 …". Worth a look at realcommonClassesvalues before and after.
Development
The source is at https://github.com/asconsulting/contao-zyppy-classes.
composer install
vendor/bin/phpunit --no-coverage
How the classes reach the markup
src/Resolver/ClassResolver.php merges an element's three fields into one
ordered, de-duplicated token list. Four listeners deliver that list, and which
one applies depends on whether the scope has an isVisibleElement call site
in Contao\Controller:
| Scope | Listener | Hook | Target |
|---|---|---|---|
| article, content element, front end module | ApplyClassesListener |
isVisibleElement |
$model->classes |
{{insert_form::x}} |
ApplyFormClassesListener |
parseTemplate |
the form wrapper's class |
| page | GeneratePageListener |
generatePage |
$objPage->cssClass (the <body> class) |
| form field | CompileFormFieldsListener |
compileFormFields |
the widget's class |
ApplyClassesListener is the important one. isVisibleElement fires before
the element is constructed, and $model->classes is the one seam Contao
honours on every rendering path — legacy Module and ContentElement
subclasses, Hybrid, and both flavours of fragment reference. That is what
lets the bundle work without re-rendering anything.
It also writes only when there is something to write: Model::__set() marks a
model modified even when set to an empty array, and a modified model is inlined
into its fragment reference instead of being passed by id.
Earlier versions applied the classes from getContentElement, getFrontendModule
and getForm by re-rendering the element after mutating it. That rendered a
ce_module four times and, for a form, re-ran processFormData() on each pass
— duplicate rows, duplicate notification e-mails, duplicate uploads. Those
listeners are gone. Do not reintroduce a render-time hook here.
Licence
Licensed under the GNU Affero General Public License v3.0 only (AGPL-3.0-only). See LICENSE. Copyright Andrew Stevens.