davidhirtz / yii2-cms
Content management system based on Yii 2.0 framework
Requires
- php: ^8.3
- davidhirtz/yii2-media: ^3.10
- davidhirtz/yii2-skeleton: ^3.15
- davidhirtz/yii2-tenant: ^3.2
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.5
- symfony/browser-kit: ^7.4
- symfony/css-selector: ^7.4
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main / 3.x-dev
- 3.13.0
- 3.12.0
- 3.11.1
- 3.11.0
- 3.10.0
- 3.9.0
- 3.8.0
- 3.7.0
- 3.6.0
- 3.5.1
- 3.5.0
- 3.4.0
- 3.3.0
- 3.2.0
- 3.1.0
- 3.0.0
- v2.x-dev
- 2.4.14
- 2.4.13
- 2.4.12
- 2.4.11
- 2.4.10
- 2.4.9
- 2.4.8
- 2.4.7
- 2.4.6
- 2.4.5
- 2.4.4
- 2.4.3
- 2.4.2
- 2.4.1
- 2.4.0
- 2.3.14
- 2.3.13
- 2.3.12
- 2.3.11
- 2.3.10
- 2.3.9
- 2.3.8
- 2.3.7
- 2.3.6
- 2.3.5
- 2.3.4
- 2.3.3
- 2.3.2
- 2.3.1
- 2.3.0
- 2.2.3
- 2.2.2
- 2.2.1
- 2.2.0
- 2.1.27
- 2.1.26
- 2.1.25
- 2.1.24
- 2.1.23
- 2.1.22
- 2.1.21
- 2.1.20
- 2.1.19
- 2.1.18
- 2.1.17
- 2.1.16
- 2.1.15
- 2.1.14
- 2.1.13
- v2.1.12
- v2.1.11
- v2.1.10
- v2.1.9
- v2.1.7
- v2.1.6
- v2.1.5
- v2.1.4.1
- v2.1.4
- v2.1.3
- v2.1.2
- v2.1.1
- v2.1.0
- v2.0.23
- v2.0.22
- v2.0.21
- v2.0.20
- v2.0.19
- v2.0.18
- v2.0.17
- v2.0.16
- v2.0.15
- v2.0.14
- v2.0.13
- v2.0.12
- v2.0.11
- v2.0.10
- v2.0.9
- v2.0.8
- v2.0.7
- v2.0.6
- v2.0.5
- v2.0.4
- v2.0.3
- v2.0.2
- v2.0.1
- v2.0.0
- v1.x-dev
- v1.3.3
- v1.3.2
- v1.3.1
- v1.3.0
- v1.2.26
- v1.2.25
- v1.2.24
- v1.2.23
- v1.2.22
- v1.2.21
- v1.2.20
- v1.2.19
- v1.2.18
- v1.2.17
- v1.2.16
- v1.2.15
- v1.2.14
- v1.2.13
- v1.2.12
- v1.2.11
- v1.2.10
- v1.2.9
- v1.2.8
- v1.2.7
- v1.2.6
- v1.2.5
- v1.2.4
- v1.2.3
- v1.2.2
- v1.2.1
- v1.2.0
- v1.1.7
- v1.1.6
- v1.1.5
- v1.1.4
- v1.1.3
- v1.1.2
- v1.1.1
- v1.1.0
This package is auto-updated.
Last update: 2026-10-11 08:23:57 UTC
README
Content management for the yii2-skeleton admin: entries (pages, articles, anything with a URL), optionally nested, put into categories and menus, and built from ordered sections; blocks are sections shared between entries. Entries, sections and blocks carry assets from yii2-media, and every entry belongs to a tenant of yii2-tenant. The bundle ships the admin and a frontend controller; the site's layout and views are the project's.
Installation
composer require davidhirtz/yii2-cms ./yii migrate ./yii search/rebuild
The bundle bootstraps itself through extra.bootstrap (Hirtz\Cms\Bootstrap): it registers the cms module, the
admin/cms submodule, the permalink console command, the @cms alias, the cms message category, the block,
category, entry and section models plus their assets on the search component, the BlockAsset, EntryAsset and
SectionAsset classes on modules.media.assets, and two URL rules: '' → cms/site/index and the catch-all
<slug:.+> → cms/site/view. The catch-all claims every path no other rule matches, so a project's own rules need a
lower position (the cms rules sit at 1000 and 1100). The migrations seed the block, category and entry
permissions, granted to admin and manager, and the author role, which holds those three, the media library's
file and folder and the skeleton's redirect. Upgrading from 2.x: see UPGRADE.md.
Configuration
modules.cms
| Property | Default | Meaning |
|---|---|---|
categoryCachedQueryDuration |
60 |
seconds the category list is cached; false disables |
defaultEntryOrderBy |
['position' => SORT_ASC] |
entry order when neither the type nor the category sets one |
defaultEntryType |
null |
entry type the admin's default links filter by |
enableBlockAssets |
true |
blocks have assets |
enableBlockEntries |
true |
blocks link entries |
enableBlocks |
false |
sections may carry a shared block |
enableCategories |
false |
entries are put into categories |
enableEntryAssets |
true |
entries have assets |
enableNestedCategories |
true |
categories form a tree |
enableNestedEntries |
false |
entries form a tree |
enableSectionAssets |
true |
sections have assets |
enableSectionEntries |
false |
sections link entries |
enableSections |
true |
entries have sections |
enableUrlRules |
true |
register the two frontend URL rules |
entryIndexSlug |
'home' |
slug of the top-level entry served at /; false serves nothing there |
entryRelations |
BlockEntry, SectionEntry |
Models\EntryRelation subclasses, one per model that links entries |
inheritNestedCategories |
true |
an entry is also in the ancestors of its categories |
menus |
[] |
Models\Menus\Menu definitions, see below |
sectionSets |
[] |
Models\Sets\SectionSet definitions, see below |
The flags cascade in init(): without sections there are no section assets and no blocks, without blocks no block
assets or block entries, without categories no nested categories, and without nested categories nothing is inherited.
A type only ever narrows what the module turned on.
Types
Entries, sections, categories and blocks are typed. Their types are fluent definitions set on the container as a closure,
because a name is usually a Yii::t() result and the configuration is read before the application has an i18n
component:
use Hirtz\Cms\Models\Entry; use Hirtz\Cms\Models\EntryAsset; use Hirtz\Cms\Models\Section; use Hirtz\Cms\Models\Types\BlockSectionType; use Hirtz\Cms\Models\Types\EntryType; use Hirtz\Cms\Models\Types\SectionType; use Hirtz\Skeleton\Models\CustomAttributes\TextCustomAttribute; 'container' => [ 'definitions' => [ Entry::class => [ 'i18nAttributes' => ['name', 'slug'], 'types' => fn (): array => [ EntryType::make(1)->name('Page')->plural('Pages')->allowCategories(false), EntryType::make(2)->name('Article')->plural('Articles') ->orderBy(['publish_date' => SORT_DESC]) ->viewFile('article') ->customAttributes([TextCustomAttribute::make('author')]), ], ], Section::class => [ 'types' => fn (): array => [ SectionType::make(1)->name('Text'), SectionType::make(2)->name('Gallery')->hiddenFields('content'), BlockSectionType::make(3), ], ], EntryAsset::class => ['translatableAttributes' => ['alt_text']], ], ],
An entry asset is the entry's preview image, so EntryAsset declares only the alt_text, loading and
fetchpriority custom attributes; section and block assets keep all of them. A project needing a caption or a link
on an entry asset overrides getDefaultCustomAttributes() in its own subclass.
Every type takes name(), plural(), viewFile(), cssClass(), customAttributes() and hiddenFields().
Per model:
EntryType:allowCategories(),allowSections(),allowDescendants(),allowAssets();orderBy()sets the index order and turns off manual ordering;sort(),showCategories(),showCategoryDropdown()configure the admin grid.SectionType:allowBlock()(off by default, needsenableBlocks),allowEntries(),entriesTypes(),entriesOrderBy(),allowAssets(); forWidgets\SectionStackon the site,visible(),group(),wrapper()andcollect();gridContent()replaces the name in the admin grid.BlockSectionTypeis a ready-made type whose sections only carry a block.CategoryType:allowDescendants(),allowEntries().BlockType:allowEntries(),entriesTypes(),entriesOrderBy(),allowAssets().
Menus and section sets
Both are declared on the module, as closures for the same reason:
use Hirtz\Cms\Models\Menus\Menu; use Hirtz\Cms\Models\Sets\SectionSet; use Hirtz\Cms\Models\Sets\SectionTemplate; 'modules' => [ 'cms' => [ 'enableCategories' => true, 'menus' => fn (): array => [ Menu::make(1)->name('Main menu'), Menu::make(2)->name('Legal')->available(fn (Entry $entry): bool => $entry->type === 1)->autoload(false), ], 'sectionSets' => fn (): array => [ SectionSet::make(1)->name('Landing page')->sections( SectionTemplate::make(1)->attribute('name', 'Intro'), SectionTemplate::make(2), ), ], ], ],
A menu an entry is put into is stored in entry.menu_ids; Models\Collections\MenuCollection loads every autoload
menu in one query. A section set creates its sections on an entry in one go and must name declared section types.
available() limits either to some entries; a menu an entry is already in stays valid.
Publishing
An enabled entry is on the site from its publish_date: the frontend's status query (EntryQuery::whereStatus(),
behind enabled(), the menus, the relations and the sitemap) leaves out one dated later, to the minute; the draft
domain previews it and the admin marks it scheduled. A type whose date means something else, an event's day, says
so with EntryType::schedule(false): its entries are listed whatever their date, and the form drops the scheduling
hint. Unset, a type schedules unless its form hides the date (hiddenFields('publish_date')). A cached page
expires when the next scheduled entry goes live. An entry's children follow its status through parent_status, not
its date: the child of a scheduled entry is reachable at its own URL, which its form warns about
(Modules\Admin\Widgets\ScheduledAncestorAlert, Entry::findScheduledAncestor()).
Admin
The aside lists every entry type under Entries when its item is told to, the type of the page being edited marked active:
'container' => ['definitions' => [ CmsNavItem::class => ['showEntryTypes' => true], ]],
The entry index takes a status parameter (admin/cms/entry/index?status=1), which also disables sorting. The grid
offers no status dropdown by default; a project that wants one adds it to the header:
use Hirtz\Cms\Models\Entry; use Hirtz\Cms\Modules\Admin\Widgets\Grids\EntryGridView; use Hirtz\Skeleton\Widgets\Grids\Toolbars\StatusFilterDropdown; use Hirtz\Skeleton\Widgets\Widget; use yii\base\Event; Event::on(EntryGridView::class, Widget::EVENT_CONFIGURE, function (Event $event): void { $event->sender->header(fn (array $header): array => [ ...$header, StatusFilterDropdown::make()->model(Entry::instance()), ]); });
Console commands
permalink/rebuild— rewrites every entry's permalinks, inserting the missing ones and deleting those of entries that no longer have a URL (after adding a language, for instance)
On the site
Controllers\SiteController resolves the slug to an entry, answers 404 for one without a URL, and renders the type's
viewFile or view, with the layout main. Widgets\SectionStack::make()->entry($entry) renders the visible
sections, grouped and wrapped as their types say; Widgets\Artwork and Widgets\Gallery render assets, and
Widgets\MetaTags the page's meta tags, canonical link, hreflang links and its structured data (below). Saving any
cms record invalidates the page cache (Module::invalidatePageCache()).
Structured data
MetaTags adds schema.org nodes to the page's graph, which the head renders as one JSON-LD script: the skeleton's
WebSite, its Organization once configured, a WebPage (a category's is a CollectionPage) with the title,
description, share image and dates, and the ancestors of a nested entry or category as a BreadcrumbList.
enableStructuredData(false) turns it off.
'container' => ['definitions' => [ \Hirtz\Skeleton\Widgets\StructuredData\Organization::class => [ 'name' => 'Example GmbH', 'logo' => '/images/logo.png', 'sameAs' => ['https://www.instagram.com/example'], ], ]],
A type changes the page node with structuredData(). The closure gets the entry (or category) and the node
MetaTags derived, and answers the node changed, null for none, or a skeleton Thing that becomes the page's
mainEntity, taking the page's name, description, image and URL unless it sets its own. Specialised data is built
from the type's custom attributes; an event takes its day from publish_date, which it then does not schedule:
use Hirtz\Skeleton\Models\CustomAttributes\DateTimeCustomAttribute; use Hirtz\Skeleton\Models\CustomAttributes\TextCustomAttribute; use Hirtz\Skeleton\Widgets\StructuredData\Event; EntryType::make(self::TYPE_EVENT) ->name('Event') ->schedule(false) ->customAttributes([ DateTimeCustomAttribute::make('end_date'), TextCustomAttribute::make('venue'), TextCustomAttribute::make('address'), ]) ->structuredData(fn (Entry $entry): Event => Event::make() ->startDate($entry->publish_date) ->endDate($entry->getVisibleAttribute('end_date')) ->location($entry->getVisibleAttribute('venue'), $entry->getVisibleAttribute('address'))),
An event without a start or a place is left out, so an incomplete entry never ships invalid data. Any other type is
a Thing::make()->type('…')->properties([...]); a closure that registers more nodes itself calls their register().
A path with a trailing slash is redirected (301) to the one without; SiteController::$redirectTrailingSlash = false
serves the entry at both:
'container' => ['definitions' => [ \Hirtz\Cms\Controllers\SiteController::class => ['redirectTrailingSlash' => false], ]],
Nothing is added to the sitemap by itself: Sitemap\EntrySitemap and Sitemap\CategorySitemap are registered on the
skeleton's sitemap component.
use Hirtz\Cms\Sitemap\CategorySitemap; use Hirtz\Cms\Sitemap\EntrySitemap; 'components' => [ 'sitemap' => [ 'sitemaps' => [ 'entries' => ['class' => EntrySitemap::class, 'enableImages' => true], 'categories' => CategorySitemap::class, ], ], ],