Search by

davidhirtz / yii2-cms

davidhirtz

Content management system based on Yii 2.0 framework

Package info

github.com/davidhirtz/yii2-cms

Homepage

Type:yii2-extension

pkg:composer/davidhirtz/yii2-cms

Statistics

Installs: 1 541

Dependents: 3

Suggesters: 0

Stars: 0

Open Issues: 0

3.13.0 2026-10-11 08:19 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, needs enableBlocks), allowEntries(), entriesTypes(), entriesOrderBy(), allowAssets(); for Widgets\SectionStack on the site, visible(), group(), wrapper() and collect(); gridContent() replaces the name in the admin grid. BlockSectionType is 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,
        ],
    ],
],