Private Craft CMS 5 plugin that periodically synchronises social media posts from the Juicer Data API and stores them as native Craft entries.
Instead of embedding Juicer through a client-side JavaScript widget (legacy JS, unoptimised images, poor Lighthouse scores), posts are fetched server-side and rendered like any other entry, so images run through Craft's native image transforms.
- Craft CMS 5.0+
- PHP 8.2+
- A Juicer API key (
jcr_…)
- A queue job (or the console command) calls
GET /v1/feeds/{feed_id}/posts?status=publicand pages through the result usingpage/per_page/meta.total_pages. Moderated and rejected posts are never requested. 429and5xxresponses trigger an exponential backoff. ARetry-Afterheader is honoured when present. Retry count and base delay are configurable.- Each post is matched against the
{{%juicer_posts}}lookup table by(feedId, externalId):- if the stored content hash still matches, the post is skipped
(rotating
?s=…URL signatures are ignored when hashing); - otherwise the entry is created or updated.
--force(or the Control Panel checkbox) re-imports everything regardless.
- if the stored content hash still matches, the post is skipped
(rotating
- Every item in the post's
mediaarray is downloaded once into a dedicated asset volume using a deterministic filename, so re-syncs reuse the existing asset instead of downloading again:imageitems → downloaded as image assets (native transforms apply);videoitems → the preview image is downloaded when the API provides one, and withdownloadVideoson the video file itself is downloaded too (maxMediaFileSizeMbcaps the size). Craft serves the video but cannot transform it.
- A failing post or media item is logged via
Craft::error()(log targetjuicer) and the sync continues.
The plugin is wired into the project as a Composer path repository (see the root
composer.json).
ddev composer require digitaldiff/juicer:dev-main
ddev craft plugin/install juicerThe install migration writes to the project config, so run it in an environment
where admin changes are allowed (local / dev) and deploy the resulting
project.yaml to the other environments.
| Type | Handle | Notes |
|---|---|---|
| Local filesystem | juicerPostsMedia |
@webroot/uploads/juicer-posts, base URL @web/uploads/juicer-posts. Override with JUICER_MEDIA_FS_PATH / JUICER_MEDIA_FS_URL. |
| Asset volume | juicerPostsMedia |
Dedicated volume for downloaded media. |
| Channel section | juicerPosts |
enableVersioning is disabled. |
| Entry type | juicerPost |
Field layout: tab Post (Title, Platform, Permalink, Media), tab Sync data (External ID, Raw Data). |
| Field | juicerExternalId |
Plain Text. Source platform's post ID, used for the upsert. |
| Field | juicerPlatform |
Plain Text (instagram, tiktok, youtube, x, …). Plain Text rather than a dropdown so unknown platform values never fail validation. |
| Field | juicerPermalink |
URL. From the post's url field. |
| Field | juicerMedia |
Assets, multiple, restricted to the juicerPostsMedia volume, image + video kinds. |
| Field | juicerRawData |
JSON. The rest of the API payload (poster, message, like_count, comment_count, share_count, view_count, moderation_status, …). |
| Table | {{%juicer_posts}} |
Maps (feedId, externalId) to an entry ID (unique index), plus a content hash to skip unchanged posts. Foreign key to elements with ON DELETE CASCADE. |
The synthetic entry title is Platform · Poster · Date. The Post Date is
taken from external_created_at and stored on the native postDate attribute
(shown in the entry sidebar, efficiently queryable via .postDate()), so no
redundant custom date field is created.
Create a key on the Developer page of your Juicer dashboard and put it in the environment:
# .env
JUICER_API_KEY=jcr_xxxxxxxxxxxxxxxxconfig/juicer.php references it by name (never commit the raw key):
<?php
return [
'apiKey' => '$JUICER_API_KEY',
// 'feeds' is intentionally omitted here so feeds can be chosen in the
// Control Panel. Uncomment to lock the selection in code instead:
// 'feeds' => [
// 'Main feed' => 123456,
// 'Campaign' => ['feedId' => 234567, 'apiKey' => '$OTHER_KEY'],
// ],
'perPage' => 100, // Juicer max is 100
'syncSinceDays' => null, // e.g. 30 to only request the last 30 days (starting_at)
'downloadVideoPreviews' => true,
// Backoff for 429 / 5xx responses
'maxRetries' => 4,
'retryBaseDelay' => 3,
'requestTimeout' => 30,
];Open Juicer in the Control Panel navigation (or Settings -> Plugins -> Juicer). The page lists every feed the API key can see, with its numeric feed ID and slug. Tick the feeds to sync and click Save selection - the choice is stored in the plugin settings (project config).
The slug shown there is the same identifier the old JavaScript embed used
(data-feed-id="agentur_diff"), which makes it easy to match an existing feed to
its numeric ID.
From the console the same list is available with:
ddev craft juicer/sync/feedsIf config/juicer.php defines a feeds key it takes precedence and the Control
Panel selection is disabled.
| Purpose | Command |
|---|---|
| Sync all selected feeds inline | ddev craft juicer/sync |
| Sync a single feed inline | ddev craft juicer/sync --feed-id=123456 |
| Queue one background job per selected feed | ddev craft juicer/sync --queue |
| List available feeds | ddev craft juicer/sync/feeds |
| Re-import everything (ignore cache) | ddev craft juicer/sync --force |
| From the Control Panel | Juicer -> Sync now (with optional "Force re-import") |
Run every 30-60 minutes via cron. The --queue variant only enqueues work and
returns immediately:
*/30 * * * * cd /var/www/html && php craft juicer/sync --queue >> /dev/null 2>&1This requires an active queue runner (php craft queue/listen as a daemon, or
CRAFT_RUN_QUEUE_AUTOMATICALLY). Use ddev craft juicer/sync directly in cron if
you would rather not depend on the queue.
Query the section like any other channel. juicerMedia can contain image and
video assets, so split them:
{% set posts = craft.entries()
.section('juicerPosts')
.orderBy('postDate DESC')
.with(['juicerMedia'])
.limit(12)
.all() %}
{% set transform = { width: 600, height: 600, mode: 'crop', format: 'webp' } %}
{% for post in posts %}
{% set raw = post.juicerRawData ? post.juicerRawData.getValue() : {} %}
{% set images = post.juicerMedia.all()|filter(a => a.kind != 'video')|values %}
{% set video = post.juicerMedia.all()|filter(a => a.kind == 'video')|first %}
{% set cover = images|first %}
<article>
{% if video %}
<video controls playsinline preload="none"
{% if cover %}poster="{{ cover.getUrl(transform) }}"{% endif %}>
<source src="{{ video.url }}" type="{{ video.mimeType ?? 'video/mp4' }}">
</video>
{% elseif cover %}
{{ tag('img', {
src: cover.getUrl(transform),
srcset: cover.getSrcset(['1x', '2x'], transform),
alt: cover.alt ?: post.title,
loading: 'lazy',
width: 600, height: 600,
}) }}
{% endif %}
<p>{{ raw.message ?? '' | nl2br }}</p>
<footer>
{{ post.juicerPlatform }} · {{ post.postDate|date('d.m.Y') }}
{% if post.juicerPermalink %}
· <a href="{{ post.juicerPermalink }}" target="_blank" rel="noopener">source</a>
{% endif %}
</footer>
</article>
{% endfor %}A complete, styled example lives in this repo's templates/index.twig: a card
grid where the whole card links to the original post, multi-image posts render as
a Swiper gallery, videos show a poster (SVG placeholder when Juicer has none),
and the full caption slides up in a panel. Two things worth copying into your
own project:
- The whole card is the link – the media and the meta bar are
<a>elements pointing atpost.juicerPermalink; the "show caption" button sits on top with its own handler. - Gallery images load on demand. Swiper clips off-screen slides, so a plain
loading="lazy"never fires for slides 2+. The example keeps the URLs indata-srcand a SwiperslideChangehandler swaps them in for the current and adjacent slides.
- Galleries are usually incomplete. Juicer's media proxy
(
www.juicer.io/api/media/…) returns404for most carousel images past the first, even with a freshly fetched, correctly signed URL — Juicer's backend simply doesn't have the files. A carousel post therefore normally ends up with 1 usable asset (sometimes a few). The plugin downloads everything the API lists, imports what resolves, and logs the rest; there is no alternative URL. Re-run with--forceafter Juicer fixes their end. - Some videos have no preview image. Newer video posts include a
preview_image_urlthat is downloaded as the poster. A few older standalone videos don't, so the example template falls back to an inline SVG placeholder poster (a play badge on a dark card) instead of showing a black<video>. There is no server-side frame extraction (noffmpegin the container); addffmpegto.ddev/config.yaml(webimage_extra_packages) and generate a poster inMediaServiceif you need real thumbnails. - Old text-only posts. For posts more than ~1–2 years old the v1 Data API
often returns
media: [](the original CDN URLs expired). Those entries are still created, just without media — render them as text cards or filter them out with.juicerMedia(':notempty:'). (The undocumented embed endpoint still returns a cover image for many of these — see the note below.)
The v1 Data API is metered/paid. Juicer also exposes the undocumented endpoint the JavaScript embed uses:
GET https://www.juicer.io/api/feeds/<slug>-<uuid>?page=1&per=100
No auth header (the <uuid> in the slug is the token), no Data API quota,
paginated (posts.items, page until empty), returns only public/approved posts,
and it still carries a cover image for the old posts the v1 API drops. Downsides:
undocumented and unsupported (can change without notice), counts toward the
feed's monthly view limit, and it has the same broken carousel media proxy.
Switching would mean a JuicerEmbedClient behind an apiMode setting — not
implemented yet.
src/
├── Plugin.php Registers services + CP screen, sync-timestamp helpers
├── migrations/
│ ├── Install.php Creates volume, section, entry type, fields, lookup table
│ └── m260827_101500_allow_video_media.php Adds the video kind to juicerMedia (1.0.1)
├── models/
│ ├── Settings.php Plugin settings (resolved from config/juicer.php)
│ └── FeedConfig.php One feed definition, with env-var resolution
├── records/PostRecord.php ActiveRecord for {{%juicer_posts}}
├── services/
│ ├── JuicerApiClient.php Bearer auth, pagination, 429/5xx backoff
│ ├── MediaService.php Downloads image + video media, deduplicated
│ ├── SyncService.php Upsert logic, content-hash skipping, title/date mapping
│ └── FeedCatalog.php Fetches + caches the feed list for the CP picker
├── jobs/SyncFeedJob.php Queue job, one per feed
├── console/controllers/SyncController.php juicer/sync, --feed-id, --queue, --force, /feeds
├── controllers/CpController.php Feed picker + "Sync now" actions
└── templates/index.twig Control Panel screen
All messages are written to the juicer log target. In dev mode they also appear
in storage/logs/web-*.log / storage/logs/console-*.log. A per-post failure is
logged and skipped; a feed-level failure aborts that feed only.
ddev craft plugin/uninstall juicerThe uninstall migration removes the section, entry type, fields, volume,
filesystem and the {{%juicer_posts}} table. Downloaded files in
web/uploads/juicer-posts are left in place.