All skills

Craft Cloud — Pixel & Tonic's serverless hosting platform for Craft CMS. Covers craft-cloud.yaml configuration, the Build → Migrate → Release deploy pipeline, the craftcms/cloud extension package, edge image transforms via Cloudflare, edge static caching with cache.rules + ESI, Cloud-managed S3 filesystem, MySQL 8 / Postgres 15 databases (no MariaDB, no tablePrefix), Console-based command runner and scheduled cron (once-per-hour minimum), auto-handled queue jobs, custom domains and SSL, preview environments per branch, Cloud limitations (ephemeral filesystem, no SSH, no .htaccess, no built-in mail), plugin development requirements for Cloud compatibility, and self-hosted → Cloud migration. Triggers on: craft-cloud.yaml, craftcms/cloud package, cloud.esi(), php craft cloud/up, php craft cloud/setup, App::isEphemeral(), CRAFT_EPHEMERAL, edge.craft.cloud, preview.craft.cloud, CRAFT_CLOUD_PROJECT_ID, CRAFT_CLOUD_ENVIRONMENT_ID, CRAFT_CLOUD_CDN_BASE_URL, Build → Migrate → Release, Cloud filesystem, Cloud-compatible plugin, Cloudflare Images at edge, AssetsFs, static-caching rules, ESI islands, deploy to Craft Cloud, migrate to Craft Cloud, self-hosted to Cloud, Craft Cloud quotas, Craft Cloud regions, request signing (RFC 9421), CRAFT_CLOUD_SIGNING_KEY, headless 429/503, cloud.artifactUrl(), @artifactBaseUrl. Do NOT trigger for Servd (use the servd skill) or generic Craft deployment on Forge/bare metal (craftcms/deployment.md). Do NOT trigger for general DDEV local dev unrelated to Cloud parity.

Use this Skill: https://skilld.dev/gh/michtio/craftcms-claude-skills/craft-cloud

This session only. Nothing lands on disk.

referencesplugin-development.md

≈3.7k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Plugin Development for Cloud Compatibility

Every constraint, recommendation, and pattern for shipping a Craft plugin that works on Cloud. Most of these are minor adjustments; a few require architectural changes (file writes, asset bundles, queue jobs).

Documentation

Table of contents

Common Pitfalls

  • Publishing CP assets at runtime via Craft::$app->getAssetManager()->publish(...) or by writing files into @webroot/cpresources. The docs are explicit: "Publishing one-off or ad-hoc assets at runtime is not supported on Cloud." Use an asset bundle with a sourcePath rooted at your plugin's alias, registered via registerAssetBundle().
  • Building <input type="hidden" name="CRAFT_CSRF_TOKEN" value="{{ craft.app.request.getCsrfToken() }}"> manually. The docs warn this "can leak one user's CSRF tokens to another" because Cloud's static cache fronts the page. Always use the csrfInput() function — Cloud force-enables asyncCsrfInputs, so the function emits an async-fetched input that's compatible with the cache.
  • An asset bundle class that hits the database in its constructor or init(). Cloud builds run without DB access; the asset publisher needs to instantiate every bundle to publish it to the CDN. The docs: "Bundle classes must be instantiable even if Craft is not installed, or cannot connect to a database."
  • Writing logs to a file via file_put_contents(), error_log(), or a custom Monolog file handler. Lambda's filesystem is ephemeral; the file vanishes before anyone reads it.
  • A queue job that processes a large dataset linearly. The 15-minute cap hits, Lambda kills the process, Craft retries from scratch, same death. Use BaseBatchedJob and split into chunks.
  • Making a synchronous external HTTP call inside EVENT_AFTER_SAVE_ELEMENT (or any save hook). A blocking, un-timeout-guarded call stalls every save — and during a resave or migration it stalls cloud/up, eating into the deploy's CLI cap. Queue it. See Don't do synchronous external I/O in save hooks.
  • A controller that sets a cookie on a cacheable site request. Once the cookie is set, the response carries Set-Cookie and bypasses the edge cache for that user. If you really do need a cookie, accept the cache bypass — but check whether you can avoid the cookie via JS-driven personalization instead.

Minimum Craft version

Craft 4.6 or later. Per the docs: "Plugins must support at least Craft 4.6 (the minimum version of Craft required to run on Cloud)."

If your plugin's composer.json requires craftcms/cms: ^5 exclusively, you're already above the floor. Plugins targeting Craft 4 should set the constraint to ^4.6 || ^5.

Asset bundles

The single biggest rule: all static assets must ship in an asset bundle, with a sourcePath that begins with your plugin's predefined Composer alias.

class MyPluginCpAsset extends AssetBundle
{
    public function init(): void
    {
        // sourcePath uses the plugin's alias, not a hardcoded path.
        $this->sourcePath = '@vendor/my-plugin/web/assets/cp/dist';

        $this->depends = [CpAsset::class];

        $this->js = ['my-plugin.js'];
        $this->css = ['my-plugin.css'];

        parent::init();
    }
}

Register the bundle properly

// In a controller action:
$this->getView()->registerAssetBundle(MyPluginCpAsset::class);

// Or in a Twig template:
{% do view.registerAssetBundle("vendor\\myplugin\\web\\assets\\MyPluginCpAsset") %}

The asset publisher (AssetBundlePublisher in the Cloud extension) walks every registered bundle at build time and uploads the files to the CDN. By release time, every js/css/@webpack-published file is already at a CDN URL.

What you can't do

  • No runtime publishing. No Craft::$app->getAssetManager()->publish(...) calls. No writes into @webroot/cpresources. The build-time publisher is the only path.
  • No assets outside the bundle path. Files in src/web/assets/cp/dist/ get published; files in src/random/path/ don't.
  • No DB-dependent bundle classes. The constructor and init() must work without a DB connection — the publisher runs in the build container, which has no DB access.

composer.json type requirement

The plugin's composer.json must declare:

{
    "type": "craft-plugin"
}

For downstream packages (libraries with their own asset bundles depended on by a plugin), the type must begin with craft or yii. Generic Composer packages (library, project, no type) won't have their assets discovered.

File writes — App::isEphemeral() gating

Cloud sets CRAFT_EPHEMERAL in the environment. Detect it in code:

use craft\helpers\App;

if (App::isEphemeral()) {
    $path = Craft::$app->getPath()->getTempPath();
} else {
    $path = $this->_legacyLocalPath;
}

file_put_contents($path . '/export.csv', $data);

Path service for transient writes

$path = Craft::$app->getPath();

$temp = $path->getTempPath();           // scratch space
$storage = $path->getStoragePath();     // longer-lived runtime state
$cache = $path->getCachePath();         // cached data

Don't hardcode paths under __DIR__, don't construct paths from Yii::getAlias('@webroot/storage/...'), don't use sys_get_temp_dir() (which works but bypasses Craft's machinery). Use the Path service uniformly — it works on self-hosted Craft too, so plugins that target Cloud also stay portable.

Pattern for Cloud-first plugins

If your plugin targets Cloud as a first-class environment, drop the if (App::isEphemeral()) fallback. Use the Path service unconditionally — it produces correct paths on every Craft deployment, Cloud or self-hosted.

Logging

Use Craft's logger, not files:

Craft::info('Sync completed: ' . $count . ' records', __METHOD__);
Craft::warning('Skipped row: ' . $id, __METHOD__);
Craft::error('API call failed: ' . $e->getMessage(), __METHOD__);

Or for structured logs:

Craft::$app->getLogger()->log(
    'API call failed',
    \yii\log\Logger::LEVEL_ERROR,
    'my-plugin',
);

These route to Cloud's log target automatically. Files written to storage/logs/myplugin.log (or anywhere else) disappear when Lambda recycles the container — which can happen between any two requests, often within seconds.

Queue jobs

Cloud auto-processes queue jobs (no scheduled runner needed). Constraints:

  • 15-minute per-job cap. Lambda kills jobs at 15 minutes.
  • Use batched jobs for long-running work. BaseBatchedJob is the canonical pattern.
  • Don't assume a job will run to completion. Design for resumption — record progress to the DB so a killed job's retry picks up where it stopped, not from scratch.

For the full queue-job authoring pattern (TTR, retries, progress reporting, batched jobs), see the craftcms skill's queue-jobs.md.

Don't do synchronous external I/O in save hooks

A blocking external call in an element save hook is a latent stall everywhere, but on Cloud it's specifically a deploy hazard. This applies equally to the element-level event (Element::EVENT_AFTER_SAVE) and the service-level event (Elements::EVENT_AFTER_SAVE_ELEMENT) — the example below uses the element event, but the hazard is the same for both. The pattern to avoid:

// Wrong — synchronous, un-timeout-guarded HTTP on every element save.
Event::on(
    Entry::class,
    Element::EVENT_AFTER_SAVE,
    function (ModelEvent $event) {
        // Blocks the request until the remote responds (or hangs).
        $this->_searchClient->upsert($event->sender);
    }
);

Why it bites harder on Cloud:

  • It stalls the request. Web requests have a 60s cap; a slow or hanging upstream burns it on every save.
  • It stalls deploys. A bulk resave/entries, or a content migration that saves elements, fires the same hook once per element — each one now waits on the network. Run during cloud/up, that serialized latency eats the deploy's CLI cap (≈890s) and can fail the Migrate phase outright.
  • It can't be retried cleanly. A save half-succeeds (element written, remote call failed) with no built-in recovery.

Queue the work instead — the save hook should enqueue, not perform, the external call:

Event::on(
    Entry::class,
    Element::EVENT_AFTER_SAVE,
    function (ModelEvent $event) {
        // Don't enqueue for drafts/revisions/propagating saves.
        if ($event->sender->getIsDraft() || $event->sender->getIsRevision() || $event->sender->propagating) {
            return;
        }

        Craft::$app->getQueue()->push(new SyncToSearchJob([
            'elementId' => $event->sender->id,
            'siteId' => $event->sender->siteId,
        ]));
    }
);

The job runs asynchronously (Cloud auto-processes the queue), carries its own TTR and retry semantics, and keeps both request handling and cloud/up off the network's critical path. If the call must be guarded inline for some reason, set an explicit short timeout on the HTTP client and swallow/log failures — never let a save block indefinitely on a third party.

CSRF and cookies

Use csrfInput(), never raw token output

{# Right — generates the async-CSRF-compatible input #}
<form method="post">
    {{ csrfInput() }}
    {# ... #}
</form>

{# Wrong — direct token output bypasses async handling and can leak tokens across users behind the edge cache #}
<form method="post">
    <input type="hidden" name="CRAFT_CSRF_TOKEN" value="{{ craft.app.request.getCsrfToken() }}">
</form>

The docs warn explicitly: building the input manually "can leak one user's CSRF tokens to another." This isn't theoretical — Cloud's edge cache will serve cached HTML to multiple users, and a baked-in token belongs to whoever generated the cache entry.

Avoid cookies on cacheable site requests

Setting a cookie via setcookie() or Craft::$app->getResponse()->getCookies()->add(...) on a site request emits Set-Cookie, which busts the edge cache for that user. If the page would otherwise be cacheable, the cookie cost is high.

Alternatives:

  • Use JS to read/write cookies client-side after the cached HTML loads.
  • Use localStorage for things that don't need to round-trip to the server.
  • Accept the cache bypass if the cookie is genuinely required (e.g. authenticated session pages).

For CP-only plugins (admin tools, not site-facing), cookies are fine — CP requests bypass the static cache by default.

Sessions

Avoid touching $_SESSION or Craft::$app->getSession() on cacheable site requests. Reading currentUser is fine — Craft handles the bypass automatically. Writing flash messages or storing data in session forces a cookie and breaks caching.

Binary responses — auto-handled

If your plugin sends file downloads via Craft::$app->getResponse()->sendContentAsFile(...), Cloud's extension intercepts and:

  1. Uploads the binary to S3.
  2. Returns a 302 redirect to a pre-signed URL.

The user's browser follows the redirect and downloads from S3 directly. This sidesteps Lambda's 6MB response cap and 60-second request timeout.

You write standard Craft code:

return Craft::$app->getResponse()->sendContentAsFile(
    $this->generateZipBytes(),
    'export.zip',
    ['mimeType' => 'application/zip']
);

No Cloud-specific branch needed. The extension does the right thing on both Cloud and self-hosted Craft.

User-uploaded files

Prefer asset selection inputs (a Craft Assets field) over direct file-upload handling in your controllers. Reasons:

  • The Assets field is integrated with Cloud's filesystem — uploads go straight to S3.
  • Validation, mime-type checking, virus scanning (if configured) are handled by Craft.
  • The user gets a familiar UI.

If you must handle uploads directly:

  • Process them inside a request that doesn't get cached.
  • Move them to a Cloud-backed filesystem via the Asset service.
  • Don't store uploads on the local Lambda disk — they vanish.

The "Tested on Craft Cloud" flag

After your plugin is verified to work on Cloud, check the Tested on Craft Cloud flag in Craft Console under your plugin's listing. This surfaces a badge in the plugin store and signals to customers that your plugin is Cloud-ready.

There's no automated certification process — the flag is a self-attestation by the plugin author, ideally after testing on an actual Cloud project.

Quick checklist

When auditing a plugin for Cloud compatibility:

  • composer.json type is craft-plugin
  • composer.json requires craftcms/cms: ^4.6 || ^5 (or higher)
  • All static assets ship in an asset bundle with sourcePath using the plugin's alias
  • Asset bundle classes instantiate without DB access
  • No runtime publish() calls or writes to @webroot/cpresources
  • Every disk write goes through Craft::$app->getPath()->... (or is gated by App::isEphemeral())
  • Logs use Craft::info/warning/error(), never files
  • Queue jobs respect the 15-minute cap (split with BaseBatchedJob if long)
  • CSRF inputs use csrfInput() function, never raw token output
  • No cookies set on cacheable site requests
  • No session writes on cacheable site requests
  • Binary responses use sendContentAsFile() (auto-handled by extension)
  • User uploads use Assets field, not direct file handling

Last verified against https://craftcms.com/docs/cloud/plugin-development on 2026-05-28.

Source: SKILL.md on GitHub

No alerts29d3 checks · Risk SAFE
  • Gen Agent Trust Hub29d

    This skill provides a comprehensive technical reference for Craft Cloud, a serverless hosting platform for Craft CMS. It offers detailed guidance on configuration, deployment pipelines, database management, and plugin development best practices without any detectable security risks.

  • Socket29d

    No alerts

  • Snyk29d

    Risk: LOW · No issues

Signed by skilld at cd2ff4a. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated last month

README badge

README badge for michtio/craftcms-claude-skills/craft-cloud