davidhirtz / yii2-skeleton
Extended framework and admin panel based on Yii 2.0 framework
Package info
github.com/davidhirtz/yii2-skeleton
Type:yii2-extension
pkg:composer/davidhirtz/yii2-skeleton
Requires
- php: ^8.3
- ext-fileinfo: *
- ext-intl: *
- ext-json: *
- ext-libxml: *
- ext-openssl: *
- ext-pdo: *
- ext-pdo_mysql: *
- ext-simplexml: *
- ext-xmlwriter: *
- bacon/bacon-qr-code: ^3.0
- guzzlehttp/guzzle: ^7.9
- jfcherng/php-diff: ^6.9
- mikehaertl/php-shellcommand: ^1.7
- robthree/twofactorauth: ^3.0
- seld/cli-prompt: ^1.0
- sentry/sentry: ^4.15
- symfony/mailer: ^7.4 || ^8.0
- symfony/polyfill-mbstring: ^1.31
- yiisoft/yii2: ^2.0
Requires (Dev)
- components/jquery: ^3.7
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.5
- symfony/browser-kit: ^7.4
- symfony/css-selector: ^7.4
- symfony/http-client: ^7.4 || ^8.0
- symfony/resend-mailer: ^7.4 || ^8.0
- yiisoft/yii2-debug: ^2.1
Suggests
- components/jquery: The Yii debugger's panels register their grid filters as jQuery, which the skeleton does not ship
Provides
Conflicts
None
Replaces
None
- dev-main / 3.x-dev
- 3.19.2
- 3.19.1
- 3.19.0
- 3.18.0
- 3.17.1
- 3.17.0
- 3.16.1
- 3.16.0
- 3.15.0
- 3.14.0
- 3.13.0
- 3.12.0
- 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.1
- 3.1.0
- 3.0.0
- v2.x-dev
- 2.6.9
- 2.6.8
- 2.6.7
- 2.6.6
- 2.6.5
- 2.6.4
- 2.6.3
- 2.6.2
- 2.6.1
- 2.6.0
- 2.5.8
- 2.5.7
- 2.5.6
- 2.5.5
- 2.5.4
- 2.5.3
- 2.5.2
- 2.5.1
- 2.5.0
- 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.20
- 2.3.19
- 2.3.18
- 2.3.17
- 2.3.16
- 2.3.15
- 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.7
- 2.2.6
- 2.2.5
- 2.2.4
- 2.2.3
- 2.2.2
- 2.2.1
- 2.2.0
- 2.1.23
- 2.1.22
- 2.1.21
- 2.1.20
- 2.1.19
- 2.1.18
- 2.1.17
- v2.1.16
- v2.1.15
- v2.1.14
- v2.1.13
- v2.1.12
- v2.1.11
- v2.1.10
- v2.1.9
- v2.1.8
- v2.1.7
- v2.1.6
- v2.1.5
- v2.1.4
- v2.1.3
- v2.1.2
- v2.1.1
- v2.1.0
- 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
- 1.9.6
- 1.9.5
- v1.9.4
- v1.9.3
- v1.9.2
- v1.9.1
- v1.9.0
- v1.8.3
- v1.8.2
- v1.8.1
- v1.8.0
- v1.7.5
- v1.7.4
- v1.7.3
- v1.7.2
- v1.7.1
- v1.7.0
- v1.6.8
- v1.6.7
- v1.6.6
- v1.6.5
- v1.6.4
- v1.6.3
- v1.6.2
- v1.6.1
- v1.6.0
- v1.5.22
- v1.5.21
- v1.5.20
- v1.5.19
- v1.5.18
- v1.5.17
- v1.5.16
- v1.5.15
- v1.5.14
- v1.5.13
- v1.5.12
- v1.5.11
- v1.5.10
- v1.5.9
- v1.5.8
- v1.5.7
- v1.5.6
- v1.5.5
- v1.5.4
- v1.5.3
- v1.5.2
- v1.5.1
- v1.5.0
- v1.4.18
- v1.4.17
- v1.4.16
- v1.4.15
- v1.4.14
- v1.4.13
- v1.4.12
- v1.4.11
- v1.4.10
- v1.4.9
- v1.4.8
- v1.4.7
- v1.4.6
- v1.4.5
- v1.4.4
- v1.4.3
- v1.4.2
- v1.4.1
- v1.4.0
- v1.3.18
- v1.3.17
- v1.3.16
- v1.3.15
- v1.3.14
- v1.3.13
- v1.3.12
- v1.3.11
- v1.3.10
- v1.3.9
- v1.3.8
- v1.3.7
- v1.3.6
- v1.3.5
- v1.3.4
- v1.3.3
- v1.3.2
- v1.3.1
This package is auto-updated.
Last update: 2026-10-18 19:43:32 UTC
README
The core of a Yii 2 based admin platform: two application classes, a database layer (Db\ActiveRecord,
Db\ActiveQuery, migrations with backups), users with roles, two-factor authentication and rate-limited login, an admin
module with a navbar, aside, grids, forms and a fulltext search, an HTML and widget layer built on htmx 4, translated
and custom attributes, redirects, a trail, a sitemap and a console. Every other davidhirtz/yii2-* bundle requires it.
It depends on yiisoft/yii2, symfony/mailer, sentry/sentry and robthree/twofactorauth (TinyMCE is bundled into
the admin's compiled assets), and needs PHP 8.3 with fileinfo, intl, json, openssl, pdo_mysql, simplexml
and xmlwriter. Image processing lives in
davidhirtz/yii2-media.
Installation
composer require davidhirtz/yii2-skeleton
The skeleton has no extra.bootstrap: it is the application. The two entry scripts build it from a configuration
array; Base\Traits\ApplicationTrait::preInitInternal() merges the core components, aliases and the admin module into
it and then reads config/params.php and config/db.php from basePath.
// web/index.php (new Hirtz\Skeleton\Web\Application(require __DIR__ . '/../config/web.php'))->run(); // yii (console entry point, executable) exit((new Hirtz\Skeleton\Console\Application(require __DIR__ . '/config/console.php'))->run());
Then, from the project root:
./yii params # writes cookieValidationKey and passwordPepper into config/params.php ./yii migrate/config # writes config/db.php ./yii migrate # applies every registered migration, backing up first ./yii user/create # the first account, prompted or with --name --email --password ./yii search/rebuild # after adding a searchable model
Web\Application serves web/ (@webroot), routes to App\Controllers, and turns on the debug module under YII_DEBUG
when yiisoft/yii2-debug is installed (never in the test environment).
Console\Application routes to App\Commands, drops the user and session components, and registers the commands
below. Both throw from current() when reached under the wrong SAPI; Web\User::current() and Web\Request::current()
answer null there instead.
Project layout
| Path | Alias | Holds |
|---|---|---|
app/Controllers/ |
@app/Controllers |
App\Controllers\*, the web controllers (site/index is SiteController::actionIndex()) |
app/Commands/ |
@app/Commands |
App\Commands\*, console commands |
app/Migrations/ |
@App/Migrations (registered during a migration run only) |
App\Migrations\M..., created with ./yii migrate/create Name |
app/ |
@app |
everything else under App\: models, a project admin module (App\Modules\Admin\Module extends Base\Module, views under @views/admin) |
resources/views/ |
@views |
views and layouts; @views/<controller id>/<view>.php |
messages/ |
@messages |
messages/<language>/app.php, the project's app category |
config/params.php, config/db.php |
read by the application; both gitignored | |
runtime/, web/ |
@runtime, @webroot |
logs, backups, uploads temp, maintenance stub; published assets, attachments/ |
Configuration
config/params.php:
| Key | Default | Meaning |
|---|---|---|
cookieValidationKey |
required | Yii's request key; ./yii params generates it |
passwordPepper |
none | appended to every password before hashing; ./yii params/pepper |
secretKey |
cookieValidationKey |
encrypts 2FA secrets and signs tokens |
adminAlias |
admin |
the URL prefix of the admin module |
hostInfo |
none | the installation's canonical URL (https://www.example.com), pinned where urlManager configures none: absolute URLs never name the host a request claimed, and the console, which has no request, builds them (its baseUrl defaults to the root) and reports itself to the registry under it |
allowedHosts |
none | comma-separated host names a request may carry (fnmatch() patterns: www.example.com, *.example.com); any other gets a 400 before routing, local hosts always pass. Without it, or a pinned hostInfo, links built from the request (password resets) name whatever host it claimed, and the admin says so |
email |
hostmaster@<host> |
the sender of every mail; the host is the request's, or under the console the one hostInfo (or urlManager.hostInfo) pins — without either, a console mail needs it set |
mailerDsn |
sendmail://default |
the Symfony mailer transport; any installed bridge's scheme (resend+api://KEY@default with symfony/resend-mailer and symfony/http-client); native://default where the host's sendmail has no -bs mode |
cookieDomain |
none | the Domain of the session and auth cookies |
cacheKeyPrefix |
none | keyPrefix of the cache component |
sentryDsn |
none | adds Log\SentryTarget under components.log.targets.sentry |
twoFactorAuthenticationIssuer |
the application name | shown in the authenticator app |
registryUrl, registryKey |
none | where ./yii registry/push reports the installation |
registryHostname |
gethostname() |
the host name reported to the registry, pinned where shell sessions land on changing nodes of one shared file system |
modules.admin (Modules\Admin\Module):
| Property | Default | Meaning |
|---|---|---|
enableSearch |
true |
the navbar search, the search actions and the index writes |
languages |
null (the i18n languages) |
the languages the admin is offered in; one language hides the picker |
languageSessionKey |
language |
session key of the picked admin language |
asideCookieName, asideCookieSecure |
_aside, null |
the collapsed-aside cookie |
translationLayout |
inline |
translated fields side by side (inline) or one language at a time (tabs) |
trailLifetime, userLoginLifetime, consentLifetime |
false |
seconds trail/clear, user-login/clear and consent/clear keep rows for |
enableConsentLog |
true |
the cookie consent log: POST application-consent and its list under "System" |
The consent log takes id (a UUID the banner keeps in its cookie), version and categories (comma-separated) from the
frontend's banner and stores them with a timestamp, no IP address. It answers 204, 400 for an invalid decision and
429 beyond controllerMap.consent.limit posts per IP and hour (30). Keep the rows as long as the consent itself plus
the limitation period, e.g. four years ('consentLifetime' => 4 * 365 * 86400).
components.user (Web\User): enableLogin, enableSignup (false), enablePasswordReset, enableUnconfirmedEmailLogin,
enableTwoFactorAuthentication, enableUserEnumerationProtection (all true unless noted), loginAttemptLimit (10, 0 off)
and loginAttemptDuration (900 s) counted per email and IP in the cache, enableDeviceCookies (a browser an account logged
in from is counted on its own, so strangers cannot lock its owner out), deviceCookieLifetime (a year), cookieLifetime
(30 days), cookieSecure (null derives from the request), disableRbacForGuests, disableRbacForOwner. The identity
cookie is _auth, the device cookie _device, the session cookie _session.
Other components the skeleton configures: request (Web\Request, environments maps host patterns to local and stage,
trustedHosts must be set behind a proxy; behind a CDN, key each range by the headers it sets,
['173.245.48.0/20' => ['X-Forwarded-For', 'X-Forwarded-Proto']]), urlManager (Web\UrlManager: i18nUrl, defaultLanguage, draftSubdomain,
redirectMap), i18n (I18n\I18N::$languages), db (Db\Connection: backupOnMigration, backupPath, maxBackups, ignoredBackupTables, lockWaitTimeout),
session (Web\DbSession, its own cookieSecure), search (Search\Search::$models, $driver), sitemap (Sitemap\Sitemap::$sitemaps, urls,
views, useSitemapIndex), upload (Upload\Upload: path, baseUrl, maxSize, uploadLimit, uploadLimitDuration, tempPath,
tempLifetime, enableGarbageCollection, and for uploads from a URL enableStreamUploads, streamUploadTimeout,
maxStreamUploadSize, maxStreamUploadRedirects, allowPrivateStreamUploadHosts),
view (Web\View::$titleTemplate), log (targets file and sentry).
Content Security Policy
The admin sends a strict policy, the contentSecurityPolicy component (Web\ContentSecurityPolicy):
script-src 'self' 'strict-dynamic' 'nonce-…'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'. Every script
Web\View renders carries the nonce (registerJs(), registerJsFile(), asset bundles, registerJsModule()); a script
added by hand has to as well, $this->renderScript($js) or nonce="<?= $this->nonce ?>". An inline event handler
(onclick) or a javascript: URL is refused. 'strict-dynamic' trusts whatever a trusted script loads itself, so a
bundle or project widens only the other directives, from its Bootstrap or the component's configuration. Styles are
unrestricted until a project adds style-src (or style-src-elem): the nonce then joins it and Web\View stamps it on
every <style> it renders, unless the directive allows 'unsafe-inline', which a nonce would cancel.
Hirtz\Skeleton\Web\Application::current()->getContentSecurityPolicy() ->addSource('frame-src', 'https://www.youtube-nocookie.com');
A frontend page sends Web\Controller::$contentSecurityPolicy,
frame-ancestors 'self'; object-src 'none'; base-uri 'self', which restricts no script and so survives a page cached for
every visitor. A controller whose pages are never cached as a whole can send the strict policy instead
($strictContentSecurityPolicy = true); a cached page cannot, since every visitor would get the nonce it was cached with.
Strict-Transport-Security is the web server's to send, for every response of the host. A project whose server does
not sets Web\Controller::$strictTransportSecurity = 'max-age=31536000'; both at once is a duplicate header scanners reject.
A model is configured through the container rather than subclassed; a type's name is a Yii::t() result, hence the closure:
'container' => [ 'definitions' => [ Hirtz\Skeleton\Models\User::class => [ 'customAttributes' => [ Hirtz\Skeleton\Models\CustomAttributes\TextCustomAttribute::make('company')->max(100), ], ], App\Models\Product::class => [ 'i18nAttributes' => ['name', 'slug'], 'types' => fn (): array => [ Hirtz\Skeleton\Models\Types\Type::make(1)->name(Yii::t('app', 'PRODUCT_TYPE_PHYSICAL')), ], ], ], ],
Cookie consent
The consent component (Consent\ConsentManager) describes what a project's cookie banner asks for: categories
(Consent\Categories\RequiredCategory, AnalyticsCategory, MarketingCategory, ExternalCategory) holding services
(Consent\Services\GoogleAnalytics, GoogleTagManager, GoogleAds, GoogleMaps, YouTube, Vimeo, ConsentCookie, CsrfCookie)
with their provider, purpose, privacy policy and cookies. The banner itself, its texts and its script are the project's;
getClientConfig() is what the script needs.
Without categories, the component offers the required cookie, analytics for tagId (params.gtagId, a GTM-…
container or a G-… stream) and YouTube as external content. A category without a service is left out. A frontend
form that sets the CSRF cookie adds CsrfCookie to the required category.
'consent' => [ 'categories' => fn (ConsentManager $consent): array => [ RequiredCategory::make(), AnalyticsCategory::make()->services(...$consent->getTagServices()), ExternalCategory::make()->services( YouTube::make(), Service::make() ->name('Calendly') ->provider('Calendly LLC') ->purpose(Yii::t('app', 'CC_SERVICE_CALENDLY_PURPOSE')) ->privacyUrl('https://calendly.com/privacy') ->cookies(Cookie::make()->name('__cf_bm')->duration('PT30M')->thirdParty()), ), ], ],
A default service is changed through the container (YouTube::class => ['purpose' => …]) or make([...]). The
version a decision is stored and logged with is version plus a fingerprint of every category, provider and cookie:
a changed service asks every visitor again on its own, a reworded text does not. Raise version for a change of
wording that should.
API keys per user
A bundle that calls a third-party service may let each user store a key of their own, declared on the credentials
component:
'credentials' => [ 'providers' => [ fn (): CredentialProvider => CredentialProvider::make('acme') ->label('Acme') ->url('https://acme.example/keys') // a single `key` unless the service needs more fields, custom attributes the form shows per provider ->fields(TextCustomAttribute::make('account')->required(), CredentialProvider::createSecretField('key', 'Key')) ->verify(fn (array $values): bool => AcmeClient::isValid($values['account'], $values['key'])), ], ],
With a provider declared, the account gets an API keys tab. A key is stored encrypted under SecretKey and shown
only by its last four characters; nobody reads it again. A manager of the user lists and removes the user's keys on the
user's API keys tab. The code calling the service asks
UserCredential::findValues($userId, 'acme'). A password change keeps the keys.
MCP server
The mcp component (Mcp\Mcp) lets an agent read and write content through the model layer, as a user and under that
user's permissions: translations, search rows, counts, trail and validation work as in the admin, and nothing takes
SQL. The tools are generic — list_resources, describe_resource (the JSON Schema of a type's attributes, its
statuses and the content languages), find_records, get_record, create_record, update_record, set_status,
reorder, and find_trail (the recorded changes of the resources the user may read, under Trail::AUTH_TRAIL_INDEX) —
over resources the bundles register; there is no delete. The server answers protocol versions
2026-07-28, 2025-11-25 and 2025-06-18.
Locally or over SSH, mcp/serve speaks stdio as --user (the owner by default), --read-only offering the read tools
alone:
claude mcp add website -- php /path/to/project/yii mcp/serve --user=me@example.com
A public site enables POST /mcp, authenticated by a token from mcp/token or the account's Access tokens tab, which shows
it once:
'mcp' => [ 'enableHttp' => true, 'allowedOrigins' => [], // a browser's `Origin` other than the site's own, `403` otherwise ],
claude mcp add --transport http website https://www.example.com/mcp --header "Authorization: Bearer <token>"
A token is shown once and stored as its HMAC (UserToken types mcp-read, mcp-write); a disabled user's tokens stop
working, and guessed tokens are counted per address (authenticationAttemptLimit, 429 past it); an expired token is
no guess, and a valid one is never refused.
enableOAuth makes the site the endpoint's own OAuth 2.1 authorization server, so claude.ai adds it as a custom connector
by URL and claude mcp add --transport http website https://www.example.com/mcp needs no header:
'mcp' => [ 'enableHttp' => true, 'enableOAuth' => true, 'allowedClientIds' => [], // `client_id` prefixes, e.g. `https://claude.ai/`; empty allows any client ],
A client identifies itself by a Client ID Metadata Document (there is no dynamic registration), the user approves it,
read-only or read-write, on the admin's consent page (admin/grant/authorize), and revokes it under the account's
Connected apps (a manager under the user's). Access tokens last accessTokenLifetime (an hour), refresh tokens
refreshTokenLifetime (30 days) and rotate; reusing a rotated one revokes the connection. The tokens are bound to the
endpoint's URL, so behind a proxy Request::$trustedHosts must be set or the metadata names http://. A password
change revokes every connection and token, as it logs out the other sessions. A change made through MCP records its
client in the trail (trail.client: the OAuth client's host, MCP token #12 or mcp/serve). A resource is a
definition appended to resources as a closure, a tool to tools:
'mcp' => [ 'resources' => [ fn (): Resource => Resource::make('product', Product::class) ->parent('shop', Shop::class, fn (Product $product, ?Shop $shop) => $product->populateShopRelation($shop)) ->exceptAttributes('sku'), ], ],
parent(), beforeInsert(), reorder(), defaultType() and url() reproduce what the model's admin actions do
besides load() and insert(); query() narrows it, attributes() and exceptAttributes() the writable attributes,
creatable(false) and readOnly() the tools, image() a picture get_record and resources/read show beside the
JSON. A later registration of a name replaces an earlier one. The skeleton
registers redirect.
Each record is also an MCP resource, record://entry/8, read through get_record and its permission check: the
templates name every resource the user may read, the list holds each one's ten most recently updated records
(enableResources turns this off). Prompts, which a client offers as commands, are definitions appended to prompts:
'mcp' => [ 'prompts' => [ fn (): Prompt => Prompt::make('new_post') ->description('Drafts a blog post') ->argument('topic', 'What the post is about', required: true) ->text(fn (array $arguments): string => "Draft a blog post about {$arguments['topic']} as an entry."), ], 'instructions' => [ fn (): string => 'A blog post is an entry of the type Article.', ], ],
initialize answers instructions: how to go about the tools, the content languages, that a write is live once
enabled; each closure in instructions appends a paragraph. A validator the schema cannot map by class describes its
attribute through Validators\Interfaces\JsonSchemaInterface; an attribute no validator types takes its column's type.
Console commands
| Command | Purpose |
|---|---|
migrate, migrate/create, migrate/config, migrate/backup, migrate/restore |
migrations with a backup first; --skipBackup (the controller's dbFile and upgradeFile are set through controllerMap) |
params, params/cookie, params/pepper, params/create, params/update, params/delete |
maintain config/params.php |
user/create, user/password <email> |
create an account, set a password (--password or YII_USER_PASSWORD) |
upgrade/passwords |
mail a reset link to every user without a password |
search/rebuild [models], search/clear [models] |
the fulltext index, optionally limited to a comma-separated list (search/rebuild Entry,Category) |
trail/clear, trail/optimize, trail/update-models |
trail retention and class renames; clear takes --sleep |
user-login/clear, user-login/optimize, user-token/clear, user-token/optimize, upload/clear |
login history (--sleep), expired tokens, abandoned uploads |
consent/clear, consent/optimize |
cookie consent log retention; clear takes --sleep |
redirect/clean |
delete redirect loops and shorten chains; --hosts, --dryRun |
maintenance/enable, maintenance/disable, maintenance |
the pre-rendered maintenance page (runtime/maintenance.php); enable takes --redirect, --retry, --refresh, --statusCode, --viewFile |
mcp/serve, mcp/token, mcp/tokens, mcp/revoke [id] |
the MCP server over stdio and the tokens of its HTTP endpoint; --user, --read-only, --expires (days) |
registry/push, registry/show |
report the installation to a version registry; --url, --strict |
upgrade |
the v2 → v3 upgrade steps the bundle runs itself |
asset/clear, email/test <address>, message |
published assets, the mailer, message extraction |
The admin module
Modules\Admin\Module is mounted as admin under params.adminAlias. Access is RBAC: one permission per managed model,
named after it (user, redirect, authUpdate, trailIndex, system), and three flat roles, admin (everything),
manager (everything but system and tenant) and, from the cms bundle, author. A role lists permissions, never
another role. A project adds a permission with Db\Traits\MigrationTrait::addPermission() and an AUTH_<NAME>_DESCRIPTION
message key, guards a controller with AccessControl naming Model::AUTH_<MODEL>, and never passes a record to can().
Extension points: a bundle module implementing Modules\Admin\ModuleInterface contributes aside(Nav) items and
dashboard(Dashboard) panels; any widget is changed from the outside through Widgets\Widget::EVENT_CONFIGURE
(Helpers\EventHelper::on(NavBar::class, Widget::EVENT_CONFIGURE, fn (NavBar $navBar) => ...)), any web controller
through Web\Controller::EVENT_CONFIGURE; Modules\Admin\Controllers\DashboardController::addRoles() widens the
dashboard's access rule. Models opt into features by interface plus trait: AdminModelInterface, TypeAttributeInterface,
StatusAttributeInterface, CustomAttributeInterface, TranslationInterface, TrailModelInterface, SearchableInterface.