flyo / nitro-yii2
Flyo Nitro Yii2 Framework Module
Requires
- php: >=8.3
- flyo/nitro-php: ^3.0
- flyo/nitro-php-bridge: ^1.1
- yiisoft/yii2: ^2.0.54
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.2
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
composer require flyo/nitro-yii2
add the module to your config
'modules' => [ 'flyo' => [ 'class' => \Flyo\Yii\Module::class, 'token' => 'YOUR_TOKEN', 'liveEdit' => !YII_ENV_PROD, // this is the default behavior, set to `true` to enable live edit in production as well ] ]
add the cms page resolve to your views in the folder /views/nitro.php, all the routes from flyo nitro will now be resolved into this view file:
<?php use Flyo\Yii\Widgets\PageWidget; /** @var \Flyo\Model\Page $page */ ?> <h1><?= $page->getTitle(); ?> <?= PageWidget::widget(['page' => $page]); ?>
In order to render those blocks use the Flyo\Yii\Widgets\PageWidget which will lookup all blocks inside the folder /views/flyo/*, so for instance you have a HeroTeaser component defined in flyo the view file is stored in /views/flyo/HeroTeaser.php with example content:
/** @var \Flyo\Model\Block $block */ print_r($block->getContent()); print_r($block->getConfig()); print_r($block->getItems()); print_r($block->getSlots());
Layout
Generate a navigation in the layout file, use the NavWidget:
<?php $nav = ContainerWidget::begin(['identifier' => 'main']) ?> <ul> <?php foreach ($nav->getItems() as $item): ?> <li><?= Html::a($item->getLabel(), $item->getHref()); ?></li> <?php endforeach; ?> </ul> <?php $nav::end(); ?>
Layout blocks with children
An example where a block contains child blocks, defined in the slot content:
<?php use Flyo\Yii\Widgets\BlockWidget; /** @var \Flyo\Model\Block $block */ $config = $block->getConfig(); ?> <div class="container"> <?php foreach ($block->getSlots()['content']->getContent() as $childBlock): ?> <div class="w-full"> <?= BlockWidget::widget([ 'block' => $childBlock, ]); ?> </div> <?php endforeach; ?> </div> <?php SectionWidget::end(); ?>
Extend existing Routes
Its possible to extend the routing system for existing pages. This can help when building dynamic sub pages which need to ensure that you are still on the same CMS page (not not entity detail), in order to do add the following url rule in the UrlManager section:
'<path:(the-requested-slug)>/<slug:[a-z\-]+>' => 'flyo/nitro/index',
In order to link to extended route, its not possible to use Url::toRoute, since this is a fixed rule in routes anyhow you have to use:
<a href="/the-requested-slug/<?= ...; ?>">Detail</a>
Live Edit
As long as the liveEdit module property is enabled, the module registers the Nitro JS Bridge together with its
boot script (page refresh, scroll to block, editor handshake and the click handlers for all elements with a
data-flyo-uid attribute) whenever a page is rendered by a web application. This happens independently of the
Editable widget, therefore live edit also works in projects which mark their blocks by hand or do not mark them
at all, and it is switched on and off in one single place.
Yii2 Widget: Editable
This widget makes Flyo blocks editable inside the Flyo preview iframe by rendering the data-flyo-uid marker,
which is picked up by the bridge registered through Live Edit.
Usage
Wrap content
<?php use Flyo\Yii\Widgets\Editable; ?> <?php Editable::begin(['block' => $block]); ?> <h2><?= $block->getTitle(); ?></h2> <p><?= $block->getText(); ?></p> <?php Editable::end(); ?>
Renders:
<div data-flyo-uid="block-uid-here">
<h2>…</h2>
<p>…</p>
</div>
Attribute only
If you already have a wrapper element, use the static helper:
<section <?= Editable::attr($block) ?>> <h1><?= $block->getTitle(); ?></h1> </section>
Renders:
<section data-flyo-uid="block-uid-here">
<h1>…</h1>
</section>
Notes
Highlighting/click-to-edit works only inside Flyo’s preview iframe.
Outside preview, the page behaves normally.
Entity Detail and Draft Links
An entity detail is rendered with the EntityAction, the finder callable resolves the entity from the api:
public function actions() { return [ 'detail' => [ 'class' => \Flyo\Yii\Actions\EntityAction::class, 'finder' => fn (\Flyo\Api\EntitiesApi $api) => $api->entityBySlug(Yii::$app->request->get('slug')), ], ]; }
Flyo can hand out a draft link for an entity which is still offline: an expiring snapshot addressed by a token
which takes the place of the unique id or the slug, so it is resolved through the very same call. Two things to keep
in mind: a draft token does not look like a normal slug or unique id, therefore an url rule which validates the
parameter against a pattern has to let it through, and typeId does not apply to a draft token.
Whenever the api answers with a draft, the action turns every cache layer off for that request — server page
cache, cdn cache and client cache — and the response is sent with Cache-Control: no-store (plus no-store for the
cdn headers and without the Last-Modified / Etag validators). A draft is deliberately never stored anywhere, so
there is no copy left which could outlive the draft or hide a change made in the meantime.
Because that decision can only be made after the entity has been resolved, controllers which serve an EntityAction
have to use Flyo\Yii\Filters\NitroPageCache instead of yii\filters\PageCache. It is the same filter, but it
throws the recorded output away when the cache has been disabled during the request:
public function behaviors() { return [ [ 'class' => \Flyo\Yii\Filters\NitroPageCache::class, 'only' => ['detail'], 'enabled' => YII_ENV_PROD && \Flyo\Yii\Module::getInstance()->serverPageCache, 'duration' => \Flyo\Yii\Module::getInstance()->serverPageCacheDuration, 'dependency' => new \Flyo\Yii\Cache\VersionCacheDependency(), 'variations' => [Yii::$app->request->getQueryParam('slug')], ], ]; }
In the view the draft state is available on the entity, use it to render a hint for the editor:
<?php if ($entity->getIsDraft()): ?> <p>Draft preview, this entity is not online yet. <?php if ($expiresAt = $entity->getDraftExpiresAt()): ?> The link expires on <?= Yii::$app->formatter->asDatetime((int) $expiresAt); ?>. <?php endif; ?> </p> <?php endif; ?>
Any action which must not be cached can use the same switch: \Flyo\Yii\Module::getInstance()->disableCache();.
CDN Caching
In production the module writes Vercel-CDN-Cache-Control and CDN-Cache-Control on every response, they are the
instructions for the edge cache in front of the application:
Vercel-CDN-Cache-Control: max-age=1800, stale-while-revalidate=900
CDN-Cache-Control: max-age=1800, stale-while-revalidate=900
max-age comes from cdnCacheDuration and says how long the edge serves a copy without asking the origin at all.
stale-while-revalidate comes from cdnCacheStaleWhileRevalidateDuration and covers what happens afterwards: instead
of every visitor waiting for a fresh render the moment the entry expires, the edge answers from the stale copy and
refreshes itself with a single background request. Without it a popular url sends a burst of concurrent requests to
the origin at every expiry, which is exactly when the origin is least able to take it.
'modules' => [ 'flyo' => [ 'class' => \Flyo\Yii\Module::class, 'token' => 'YOUR_TOKEN', 'cdnCacheDuration' => 1800, 'cdnCacheStaleWhileRevalidateDuration' => 900, // set to 0 to omit the directive ] ]
Set cdnCache to false to tell the edge to store nothing, the client cache (clientHttpCache) stays independent
of it. A request which called disableCache() always wins over all of this and is sent with no-store.
Server Page Cache and the Version Check
With serverPageCache enabled the rendered page and the nitro config are kept in the yii cache for
serverPageCacheDuration seconds. Flyo\Yii\Cache\VersionCacheDependency makes that safe: it asks the flyo version
api whether the content has changed, so a long duration never delivers outdated content.
Both switches hang on the same flag, which is worth knowing before turning it off:
serverPageCache |
flyo api calls per request which reaches the origin |
|---|---|
true |
1, the version check |
false |
2, the config and the page, both uncached |
Turning the server page cache off does not make the module quieter, it makes it louder. It only removes the version check, and in exchange every request resolves the config and the page against the api again.
versionCheckInterval
Yii evaluates a cache dependency on every read of a cache entry, so by default the version api is asked once per request which reaches the origin. The answer is tiny, but php keeps no connection pool between requests, so it costs a dns lookup plus a tcp and a tls handshake before the request itself.
versionCheckInterval reuses the last answer for that many seconds. It is 0 (off) by default:
'flyo' => [ 'class' => \Flyo\Yii\Module::class, 'token' => 'YOUR_TOKEN', 'versionCheckInterval' => 300, // seconds, 0 disables the throttling ],
The api can not be asked more than once per interval, so the calls per day are capped at 86400 / interval. As long
as requests arrive further apart than the interval, nothing is saved:
| interval | at most ... version calls per day | saves nothing below ... requests per day |
|---|---|---|
| 30s | 2,880 | 2,880 |
| 60s | 1,440 | 1,440 |
| 120s | 720 | 720 |
| 300s | 288 | 288 |
Share of the calls which is removed:
| requests per day | 30s | 60s | 120s | 300s |
|---|---|---|---|---|
| 1,000 | 0% | 0% | 28% | 71% |
| 5,000 | 42% | 71% | 86% | 94% |
| 10,000 | 71% | 86% | 93% | 97% |
| 50,000 | 94% | 97% | 99% | 99% |
| 1,000,000 | 100% | 100% | 100% | 100% |
Choosing the interval
What the interval costs is precision: content published in flyo is picked up by the server side cache up to this many
seconds later. Pick it against the cache layer in front of it, not in absolute terms. With a cdn the edge already
holds a copy for cdnCacheDuration plus cdnCacheStaleWhileRevalidateDuration, so a few minutes at the origin
disappear in that window:
| interval | worst case a visitor can see, with the default 1800s + 900s cdn settings |
|---|---|
0 |
45min 00s |
30 |
45min 30s |
60 |
46min 00s |
120 |
47min 00s |
300 |
50min 00s |
300(5min) when a cdn is in front, which is the normal setup. It removes more than 90% of the calls from roughly 3,000 requests per day upwards and costs 5 of the 45 minutes the edge is stale anyway.30to60whencdnCacheis off and the server page cache is the only layer, because then the interval is the whole staleness a visitor can see.0when an editor has to see a publish immediately without a cdn purge, for example on a staging or review deployment.
The value is stored in the cache which evaluates the dependency, so a shared cache (redis, memcached) means one call
per interval for the whole cluster, a per node cache (file cache, and every serverless instance has its own) means one
call per interval per node. A DummyCache stores nothing and therefore behaves exactly like 0.