enovate/social-stream

Pull posts from Instagram (and other social platforms) into Craft CMS templates

Maintainers

Package info

github.com/enovatedesign/social-stream

Type:craft-plugin

pkg:composer/enovate/social-stream

Transparency log

Statistics

Installs: 260

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.3.3 2026-07-31 14:36 UTC

This package is auto-updated.

Last update: 2026-07-31 14:36:36 UTC


README

A Craft CMS 5 plugin for pulling Instagram posts into your templates via the Instagram Graph API. Supports stream filtering, carousel children, caching with stale-while-revalidate, and multi-site configurations.

Requirements

  • Craft CMS 5.0 or later
  • PHP 8.2 or later
  • An Instagram Business or Creator account linked to a Meta Business Suite page
  • A Meta App with the Instagram product configured

Installation

Install via Composer:

composer require enovate/social-stream

Then install the plugin via the Craft CP under Settings > Plugins, or from the command line:

php craft plugin/install social-stream

Updating

Some releases include database migrations (1.3.0 adds a column to the connections table). Run Craft's update command after pulling a new version:

php craft up

Before you start

Authorisation has to happen on a publicly accessible URL, because Instagram's OAuth callback needs to reach your site. You can't complete it against a local domain unless you tunnel it out with expose.dev, herd share, or similar.

You don't need the Instagram credentials yourself, but someone who has them must be available for two steps:

  • Under "3. Set up Instagram login", step 2, item 4 — to approve the Instagram tester role
  • Everything under "4. Instagram login and Authorise" — essentially going through the Instagram OAuth flow from the Craft CMS control panel

Setup

1. Create a Meta App

  1. Go to Meta for Developers and create a new app.
  2. Give it a name (e.g. "My Site Social Stream") and enter the App contact email, then click "Next".
  3. On the "Add use cases" screen, under "Filter by" select "Content management" then click "Manage messaging & content on Instagram", then click "Next".
  4. On the "Which business portfolio do you want to connect to this app?" screen select the last option for "I don't want to connect a business portfolio yet", then click "Next".
  5. On the "Publishing requirements" screen click "Next".
  6. On the "Overview" screen, click "Create App".

2. Customise the app's permissions

  1. Click on the pencil icon from the menu on the left to get to the "Use cases" screen for your app.
  2. You should see "Manage messaging & content on Instagram", click on the "Customize" button next to it.
  3. On the "Customize use case" screen click on "Permissions and features", then click "+ Add" next to "instagram_business_basic".
  4. Then click Actions > Remove for both "instagram_business_manage_messages" and "instagram_manage_comments". The plugin doesn't use these permissions, and removing them avoids triggering Meta's App Review requirement for them.

3. Set up Instagram login

  1. Click on "API setup with Instagram login", then note your Instagram App ID and Instagram App Secret.
  2. Click on the "Roles" link (under "2. Generate access tokens"), which will take you off to the App roles screen in a new browser tab, where...
    1. Click on the "Add People" button in the top right.
    2. Select "Instagram Tester" under "Additional roles for this app".
    3. Enter the Instagram account username into the search field and select the account, click the "Add" button.
    4. Then log in to that Instagram account and go to https://www.instagram.com/accounts/manage_access/ where you will need to approve the Instagram tester role.
    5. Return to the previous browser tab.
  3. Back under "2. Generate access tokens", you don't need to click "Add account" or copy a token — the plugin handles the token exchange when you click Authorise later.
  4. Under "4. Set up Instagram business login" click on the "Setup" button, add the following URL to the OAuth redirect URIs field: https://your-site.com/actions/social-stream/auth/callback. Replace "your-site.com" with your Craft installation's primary site domain (including "www." if your site uses it) — the plugin always uses the primary site's base URL for the callback, even on multi-site installs.

The value you enter into the OAuth redirect URIs field should be a publicly accessible URL as this is where the callback from Instagram will land. You can set it up with a public staging URL and then add a production callback URL later. URLs can be added/removed at Meta under "4. Set up Instagram business login" by clicking on the "Business login settings" button.

4. Instagram login and Authorise

Please note:

  • The next steps need to be performed by someone who has both the "Access Social Stream" permission in Craft CMS and the Instagram account login.
  • You can use environment variables for your Instagram App ID and Instagram App Secret, if so set those up now.
  • These steps must be performed in the environment whose domain you entered in the OAuth redirect URIs field earlier.
  • The plugin exchanges the authorisation code for a long-lived token (60-day validity) and stores it encrypted in the database. A masked preview of the token and its expiry date are shown in the Connection Status panel.
  1. Log in to the Instagram account first.
  2. In Craft CMS navigate to "Social Stream" from the left hand menu
  3. On the "Connection" tab enter your Instagram App ID and Instagram App Secret (or your environment variable names if you set them up), then click "Authorise".
  4. You'll be taken to Instagram to approve the connection, returning you to the Social Stream Connection tab, where the Connection Status panel should now show Status: Connected.

With that done the connection is set up. You may want to review the settings on the "Configuration" tab, and on the "Stream Preview" tab click on "Load Stream Preview".

Meta App Review

You can use the app in Development Mode with your own Instagram account added as a test user, this seems to work just fine.

Configuration

CP Settings

Navigate to Social Stream in the CP sidebar. The settings page is organised into three tabs:

Connection Tab

Setting Description Default
Instagram App ID From the Meta Developer portal. Supports $ENV_VAR syntax.
Instagram App Secret From the Meta Developer portal. Supports $ENV_VAR syntax.

An Authorise button starts the OAuth flow to connect your Instagram account. Once connected, the Connection Health panel is displayed here (see below).

Configuration Tab

Setting Description Default
Default Post Limit Number of posts to return when a template doesn't specify one (1-100). 25
Exclude Non-Feed Posts Exclude posts not shared to the main feed (e.g. Reels-only). Off
Cache Duration How long to cache stream data, in minutes. 60

API Tab

Setting Description Default
Secure API Endpoint Enable the optional JSON API endpoint. Off

When enabled, a Generate Token button creates a bearer token for API access. The token is shown once and cannot be retrieved later.

Config File Overrides

All CP settings can be overridden via config/social-stream.php:

<?php

return [
    'defaultLimit' => 12,
    'cacheDuration' => 120,
    'excludeNonFeed' => true,
    'secureApiEndpoint' => false,
    'maxFetchPages' => 5,
    'fetchPageSize' => 25,
];
Key Type Default Description
defaultLimit int 25 Default number of posts to return when a template doesn't specify one (1-100)
excludeNonFeed bool false Exclude posts where is_shared_to_feed is false
cacheDuration int 60 Cache TTL in minutes
secureApiEndpoint bool false Enable the JSON API endpoint
maxFetchPages int 3 Max API pages to fetch when filtering reduces results
fetchPageSize int 25 Items requested per API page when filtering is active (1-100)

Filtering and limit

excludeNonFeed and mediaType are applied by the plugin, not by Instagram — the API has no way to filter on them. The plugin therefore over-fetches: it requests fetchPageSize items per page and keeps paging, up to maxFetchPages, until it has collected limit posts that survive the filter.

This matters most when limit is small. Asking for 3 posts does not mean only 3 posts are examined; a full page is fetched and filtered down. Without that, a template asking for 3 posts from an account where most posts are filtered out would render one or two tiles, or none at all.

If a filtered stream is still returning fewer posts than you asked for, the account has fewer matching posts than maxFetchPages × fetchPageSize reaches back. Raise fetchPageSize first — it costs the same number of API calls — then maxFetchPages.

Template Usage

Fetching the Stream

{% set stream = craft.socialStream.getStream({
    provider: 'instagram',
    limit: 12,
    mediaType: 'IMAGE',
    excludeNonFeed: true,
    siteId: currentSite.id,
}) %}

{% if stream.success %}
    {% for post in stream.data|filter(post => post.hasMedia()) %}
        <a href="{{ post.permalink }}">
            <img src="{{ post.images[0].url }}" alt="{{ post.caption }}">
        </a>
    {% endfor %}
{% else %}
    <p>Instagram feed is temporarily unavailable.</p>
{% endif %}

A post can arrive with nothing renderable attached — Instagram omits media URLs from posts it considers copyright-encumbered, and a carousel's children can fail to fetch. Filtering on hasMedia() keeps images[0] safe to index; without it, one such post is a fatal Key "0" does not exist error on the whole page.

Parameters

Parameter Type Default Description
provider string Required Which provider to fetch from (e.g. 'instagram')
limit int CP setting Number of posts to return
mediaType string|null null (all) Filter: IMAGE, VIDEO, CAROUSEL_ALBUM
excludeNonFeed bool CP setting Exclude posts where is_shared_to_feed is false
siteId int Current site Which site's connection to use
after string|null null Pagination cursor from a previous response's nextCursor

Response Contract

Every call to getStream() returns a consistent object:

Property Type Description
success bool Whether the fetch succeeded
data Post[] Array of Post objects (empty on failure)
nextCursor string|null Cursor for the next page
error string|null Error message (null on success)
cached bool Whether served from cache

An API failure always produces success: false with the provider's message in error. A failure part-way through pagination fails the whole fetch: posts already collected are discarded rather than returned as a shorter success, since a truncated stream cached for the full TTL is indistinguishable from a healthy one. Where a previous response is still inside its stale window, that is served instead.

Failed responses are never cached as responses, but the failure itself is remembered for 5 minutes: during that window requests return the same error without another API call, so a sustained upstream outage costs one call per 5 minutes rather than one per page view. Recovery is automatic once the window passes.

Post Properties

Each Post object in stream.data provides:

Property Type Description
id string Provider-native post ID
provider string Provider handle that produced this post (e.g. 'instagram')
caption string|null Post caption or title
permalink string|null URL of the post on the provider
timestamp DateTime|null Post publish time
likeCount int|null Number of likes
commentsCount int|null Number of comments
author PostAuthor|null Author of the post — see below
images PostMedia[] Image attachments — see below
videos PostMedia[] Video attachments — see below
children Post[] Carousel children (empty for non-carousels)
meta array Provider-specific extras (e.g. isSharedToFeed, mediaProductType, shortcode)
raw array Untransformed API response — escape hatch for debugging

Post also exposes a hasMedia() method — true when the post has an image, a video, or a carousel child that has one. Use it to skip posts with nothing to render, as in the example above.

PostMedia exposes type ('image' or 'video'), url, thumbnailUrl, width, height.

Instagram omits media_url from a video's response when the media contains copyrighted content — typically a reel with licensed audio, and it can start happening to a post long after it was published. Those posts arrive with an empty videos array and their thumbnail in images instead, so they still render as a still that links out to permalink, which is where the video plays anyway. Check videos|length before reaching for a playable URL rather than assuming meta.mediaType == 'VIDEO' guarantees one.

PostAuthor exposes id, name, handle, url, avatarUrl. For Instagram, only id and handle are populated from the stream response — call craft.socialStream.getProfile() for richer account data (username, profile picture, follower count).

Carousel Rendering

{% for post in stream.data %}
    {% if post.children|length %}
        <div class="carousel">
            {% for child in post.children %}
                {% if child.videos|length %}
                    <video src="{{ child.videos[0].url }}" poster="{{ child.videos[0].thumbnailUrl }}" controls></video>
                {% elseif child.images|length %}
                    <img src="{{ child.images[0].url }}" alt="">
                {% endif %}
            {% endfor %}
        </div>
    {% elseif post.videos|length %}
        <video src="{{ post.videos[0].url }}" poster="{{ post.videos[0].thumbnailUrl }}" controls></video>
    {% elseif post.images|length %}
        <img src="{{ post.images[0].url }}" alt="{{ post.caption }}">
    {% endif %}
{% endfor %}

Pagination

{% set cursor = craft.app.request.getQueryParam('after') %}
{% set stream = craft.socialStream.getStream({
    provider: 'instagram',
    limit: 6,
    after: cursor,
}) %}

{% if stream.success %}
    {% for post in stream.data %}
        {# render posts #}
    {% endfor %}

    {% if stream.nextCursor %}
        <a href="{{ url(craft.app.request.pathInfo, { after: stream.nextCursor }) }}">
            Load more
        </a>
    {% endif %}
{% endif %}

Profile Information

{% set profile = craft.socialStream.getProfile({ provider: 'instagram' }) %}

{% if profile.success %}
    <p>{{ profile.data.username }} — {{ profile.data.followers_count }} followers</p>
{% endif %}

Cron Setup

A single cron entry handles both stream cache pre-warming and Instagram token refresh:

# Social Stream — pre-warms the cache and refreshes expiring tokens
7,37 * * * * cd /path/to/craft && php craft social-stream/refresh

Each run pushes a RefreshStreamJob per connection, and additionally queues a RefreshTokenJob for any connection whose Instagram token is within 7 days of expiry. No separate daily cron for token refresh is needed — it's handled opportunistically.

Token refresh happens only on this path, so a cron that silently never runs will let a token expire with nothing else to signal it. Verify yours actually fires — cron uses a minimal PATH, so an unqualified php that works in your shell may not resolve there.

RefreshTokenJob retries on a 1 → 5 → 30 minute backoff and then fails the job, so an unrecoverable refresh is visible in the CP's Queue Manager. A credential the provider has rejected outright is the exception: retrying it is pointless, so the job stops immediately and the cron skips the connection from then on, reporting it in the command output. The signal in that case is the CP banner — see Instagram has rejected this token.

Cadence: set this to roughly half of your configured cacheDuration (default: 60 minutes → every 30 minutes). That gives one pre-warm per fresh window plus a safety margin if a cron run is missed.

Pick random minute offsets. The example above uses 7,37 rather than 0,30 or */30. Running exactly on the hour means every Social Stream install hits Meta's API at the same instant, which strains their rate limits and slows your own requests. Choose any two minute values 30 apart that suit your infrastructure.

Options accepted by social-stream/refresh:

  • --site=<id> — scope to a single site (otherwise all sites with a connection are refreshed)
  • --provider=<handle> — scope to a single provider (e.g. instagram)
  • --force-token — queue a token refresh for every matched connection regardless of expiry

Running on multiple web hosts

The cron command is safe to run on every web host in a load-balanced setup. Before pushing either kind of job, the plugin checks the Craft queue table (via the primary database connection, so read-replica lag can't mislead it) and skips the push if an identical pending job is present or if an identical job failed within the last two hours. No server-affinity or cron-on-one-host-only configuration is required — though you're free to run cron on a single host if you prefer.

Manual token refresh

The consolidated cron handles token refresh automatically. You only need to run the manual command after re-authenticating or if you want to force-refresh a token early:

php craft social-stream/token/refresh           # queue refresh for all sites
php craft social-stream/token/refresh --site=1  # queue refresh for a specific site

This command uses the same queue-table dedup as the cron, so it's also safe on multiple hosts.

JSON API Endpoint

An optional JSON API is available at /actions/social-stream/api for external consumers (e.g. JavaScript front-ends, mobile apps).

Enabling

  1. Toggle Secure API Endpoint to on in the API tab.
  2. Click Generate Token to create a bearer token.
  3. Copy the token immediately — it is shown once and cannot be retrieved later.

Usage

curl -H "Authorization: Bearer YOUR_TOKEN" \
  "https://your-craft-site.com/actions/social-stream/api?provider=instagram&limit=12&mediaType=IMAGE"

Query parameters: provider (required), limit, mediaType, excludeNonFeed, after, siteId.

The response matches the same contract as craft.socialStream.getStream().

Connection Health Panel

When a token is connected, the Connection tab displays a health panel showing:

  • Token status — green (valid), amber (expiring within 7 days), red (expired, or rejected by the provider)
  • Token expiry date
  • Last successful fetch timestamp
  • Last error message and timestamp
  • Rate-limit cooldown — active or inactive
  • API version in use

Two action buttons are available:

  • Test Connection — makes a GET /me call and displays the account name and type. This is a live call even when the connection is flagged as needing re-authorisation, so using it clears the flag on a connection that has recovered. It deliberately leaves Last successful fetch and Last error alone: it fetches no posts, so it has nothing to say about the stream.
  • Refresh Stream Now — queues a background stream refresh immediately

Last successful fetch is only stamped by a stream fetch that actually returned posts — not by cache hits, failed requests, or a profile call — so a stale timestamp is meaningful rather than merely quiet. Last error persists until a stream fetch succeeds.

Multi-Site Support

Each Craft site can connect a different Instagram account with independent settings. Use the site switcher at the top of the settings page to configure each site.

In templates, the siteId parameter defaults to the current site. To explicitly request a different site's stream:

{% set stream = craft.socialStream.getStream({ provider: 'instagram', siteId: 2 }) %}

Caching

The plugin caches stream responses using Craft's cache component (respects your configured driver: file, Redis, Memcached, etc.).

  • Cache duration is configurable per site (default: 60 minutes).
  • Stale-while-revalidate: expired cache data is served immediately while a background job refreshes the content.
  • Stampede protection: mutex locks prevent multiple simultaneous API calls when the cache expires.
  • Failure backoff: a failed fetch is remembered for 5 minutes and replayed from that memory, so an upstream outage can't turn every uncached request into a live API call. Failed responses themselves are never cached as content.
  • Cache clearing: use Utilities > Caches > Invalidate data caches > Social Stream data in the CP, or run php craft invalidate-tags/social-stream from the CLI.

Troubleshooting

"Insufficient developer role" during authorisation

If the OAuth flow lands on a Meta error page reading "Insufficient developer role" (URL contains instagram.com/oauth/authorize/third_party/error/), the browser is logged into an Instagram account that hasn't been added as a tester on the Meta App.

Before clicking Authorise (or Re-authorise), make sure the browser is logged into the same Instagram account that was added as an Instagram tester in step 3.2 of the setup — and that the tester invite has been accepted at instagram.com/accounts/manage_access/.

The cleanest way to be certain:

  1. Open an incognito/private window.
  2. Go to instagram.com and log in with the account you want to connect.
  3. Return to the Craft CP Connection tab and click Authorise.

This avoids any session confusion with personal Instagram accounts you may be signed into elsewhere.

Token has expired

The token must be refreshed before its 60-day expiry. Set up the consolidated cron (php craft social-stream/refresh) to handle this automatically — it queues a token refresh once a token is within 7 days of expiring. You can also re-authorise from the Connection tab.

If a token expired anyway, check that the cron is genuinely running before looking anywhere else — a crontab entry that fails every time is silent. Simulate cron's stripped environment with env -i /bin/sh -c '<your cron line>'; if it fails there but works in your shell, use the absolute binary path from which php in the crontab.

The plugin logs every refresh outcome to storage/logs/social-stream-*.log, so grep -iE 'token refresh|refreshed token' is the fastest way to tell "the refresh is being rejected" from "the refresh never ran".

Instagram has rejected this token

Distinct from an expired token: the provider returned OAuthException code 190, meaning the credential was expired, revoked, or invalidated (e.g. by an Instagram password change). Meta will not refresh a token in this state, so re-authorising from the Connection tab is the only route back.

While the connection is in this state the plugin suspends stream API calls rather than repeating a request it knows will fail, and the cron stops queueing token refreshes for it — Meta will not refresh a credential it has already refused.

The flag clears in three ways: re-authorising, a successful Test Connection, or the hourly probe. The probe lets a single stream request through every 60 minutes; if the rejection was transient and the credential works again, that request succeeds and the flag clears with no intervention. A genuinely dead token simply fails the probe, and suppression continues at a cost of one API call an hour.

Wrong account type

The Instagram Graph API requires a Business or Creator account. Personal accounts are not supported. Convert your account in Instagram's settings under Account > Switch to professional account.

Rate limited

If the Instagram API returns a rate-limit error (HTTP 429), the plugin enters a 15-minute cooldown. During this time, stale cached data is served instead of making API calls. The cooldown status is visible in the Connection Health panel.

Missing fields

Some fields (e.g. like_count, comments_count) may not be returned depending on your app's permissions or the media type. The plugin defaults missing values to null gracefully. Ensure your Meta App has the required permissions approved.

A video renders as a still image

Instagram omits media_url from a video's response when the media contains copyrighted content — most often a reel with licensed audio. It can start doing so long after the post was published, so a feed that has been working for months can change behaviour with no code change at either end.

There is nothing to fix on the Craft side: no URL is served, so the video cannot be embedded. The plugin falls back to the post's thumbnail, which Instagram still provides, so the post renders as a still. Link it to post.permalink and the video plays on Instagram, where the audio licence applies. The post is identifiable in a template as post.meta.mediaType == 'VIDEO' with an empty post.videos — enough to overlay a play badge if you want it to read as a video rather than a photo.

If Instagram withholds the thumbnail too, the post has nothing renderable at all and hasMedia() returns false, so filtering on it (see Fetching the Stream) skips the post instead of failing the page.

Using the health panel

The Connection Health panel on the Connection tab provides at-a-glance diagnostics:

  • A red token status means either the stored expiry has passed — re-authorise, and check your cron setup, since the refresh should have run 7 days earlier — or that Instagram has rejected the token outright, which only re-authorising fixes.
  • A "Last Error" entry shows the most recent API failure. It's cleared by the next successful fetch, so an empty entry alongside a stale "Last successful fetch" is itself a signal.
  • An active rate-limit cooldown means the API is temporarily suppressed.

Use the Test Connection button to verify the API is responding correctly.

API Version

The plugin targets Instagram Graph API v21.0 via graph.instagram.com. The version is centralised as a constant (InstagramProvider::API_VERSION) and displayed in the Connection Health panel.

Extending

Registering a custom provider

Plugins and modules can register their own providers so craft.socialStream.getStream({ provider: 'myprovider' }) works out of the box.

use enovate\socialstream\services\Providers;
use craft\events\RegisterComponentTypesEvent;
use yii\base\Event;

Event::on(
    Providers::class,
    Providers::EVENT_REGISTER_PROVIDER_TYPES,
    function (RegisterComponentTypesEvent $event) {
        $event->types[] = MyProvider::class;
    }
);

Your provider should extend enovate\socialstream\base\Provider, implementing handle(), doFetchStream(), and doFetchProfile(). Optionally override displayName() to supply a human-readable name. The base class handles rate-limit state, error recording, last-fetch timestamps, and lifecycle events.

Lifecycle events

Two events are emitted on every fetch. Use EVENT_BEFORE_FETCH_STREAM with $event->handled = true and $event->result = [...] to short-circuit the API call, or mutate $event->result in EVENT_AFTER_FETCH_STREAM to transform the response before it reaches the caller.

use enovate\socialstream\base\Provider;
use enovate\socialstream\events\FetchStreamEvent;
use yii\base\Event;

Event::on(
    Provider::class,
    Provider::EVENT_AFTER_FETCH_STREAM,
    function (FetchStreamEvent $event) {
        // Only keep posts that mention a specific hashtag.
        if (!empty($event->result['data'])) {
            $event->result['data'] = array_filter(
                $event->result['data'],
                fn($post) => str_contains((string) $post->caption, '#featured'),
            );
        }
    }
);

Background refresh event

RefreshStreamJob::EVENT_AFTER_REFRESH_STREAM fires after a background refresh successfully replaces the cached stream payload. The typical use case is invalidating a downstream cache (e.g. a CDN or Varnish fronting the page that renders the stream) so visitors see the new posts.

The event fires only when all of the following are true:

  • The refresh ran via RefreshStreamJob — i.e. the cron entry point (php craft social-stream/refresh) or the queue. Cold-miss synchronous fetches on the front-end do not trigger it; those happen inside a single render and don't represent a change in upstream data.
  • The provider's fetchStream() returned success: true. A failed fetch leaves the previous cache in place and is not signalled.
  • The new payload differs from what was already cached. If the refresh produced a byte-identical response (same posts, same order, same engagement counts), nothing downstream needs to invalidate, so the event is suppressed.

The event is dispatched from RefreshStreamJob, so subscribers attach to that class:

use enovate\socialstream\events\StreamRefreshedEvent;
use enovate\socialstream\jobs\RefreshStreamJob;
use yii\base\Event;

Event::on(
    RefreshStreamJob::class,
    RefreshStreamJob::EVENT_AFTER_REFRESH_STREAM,
    function (StreamRefreshedEvent $event) {
        // $event->siteId, $event->provider, $event->options, $event->response
        // ...purge a CDN, ping a webhook, etc.
    }
);

The event runs in the queue worker (console) context, not in a web request, so listeners that need a request URL should derive it from the site's base URL via $event->siteId rather than reading it from Craft::$app->getRequest().