knplabs/knp-last-tweets-bundle
KNPLabs LastTweetsBundle is a Symfony bundle for fetching and displaying a user's latest tweets. It offers simple configuration and Twig integration so you can embed recent tweet lists in templates with minimal setup, making it easy to add Twitter feeds to Symfony applications.
Read this before running composer require: you won't. The package is a Symfony2 Bundle — its value lives in a Bundle class, a DependencyInjection extension, service YAML, and a Twig extension. Composer will also refuse: it pins Symfony ^2.3 and PHP ~5.3|~7.0, which conflicts with any Laravel 10/11/12 install on PHP 8.1+. And its reason to exist — an unauthenticated call to the Twitter REST v1.1 user-timeline endpoint — returns 410/404 today; X moved to v2 with mandatory OAuth 2.0 bearer tokens and a paid free tier. So the "getting started" path for a Laravel developer is: read it as a reference spec, then build ~200–400 LOC of first-party Laravel code. Treat the bundle as a shape, not a dependency.
Where to look first. Clone to a throwaway directory (never into your app) and read three files, in this order:
knp_last_tweets service id. This is the entire "contract": how it takes a username and count, calls the HTTP client, and hands back a list..twig). Note what is escaped ({{ }}) versus raw ({{ x|raw }}) — this is your XSS reference, and the part you must not copy blindly.Also open DependencyInjection/Configuration.php (or the equivalent YAML node definition) — that's where username, count, and cache lifetime are declared, and it's the direct analogue of what you'll put in config/last-tweets.php.
First use case — the smallest viable thing. A single Blade component rendering the last N tweets on a marketing page, backed by the official X embed, not the API. Concretely, in half a day:
// config/last-tweets.php
return [
'handle' => env('LAST_TWEETS_HANDLE', 'laravel'),
'count' => 5,
'ttl' => 600, // seconds; mirrors the bundle's cache lifetime knob
];
{{-- resources/views/components/last-tweets.blade.php --}}
@props(['handle' => config('last-tweets.handle'), 'count' => config('last-tweets.count')])
<a class="twitter-timeline"
data-tweet-limit="{{ $count }}"
href="https://twitter.com/{{ $handle }}?ref_src=twsrc%5Etfw">
Tweets by {{ $handle }}
</a>
@once
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
@endonce
Use <x-last-tweets /> in the view. No credentials, no rate limits, no maintenance burden — X owns the auth, the API, and the rendering. This is the day-to-day answer for ~90% of "we want a Twitter feed" requests. Only walk away from this path if a stakeholder can articulate a concrete reason server-side rendering is required (e.g., SEO-crawlable tweet text, custom markup that the embed won't produce, or content that must survive ad-blockers).
If a requirement genuinely forces server-side rendering, port the bundle's shape into a first-party Laravel package rather than resurrecting it. The bundle's design is small and sound; the wiring is what's dead.
Service + cache + partial — the ported architecture. The bundle decomposes into three concerns; map them straight onto Laravel:
| Bundle concept | Laravel equivalent |
|---|---|
KnpLastTweetsBundle service definition |
ServiceProvider + singleton binding |
DI config node (username, count, cache_ttl) |
config/last-tweets.php + .env |
| Cache-backed fetch service | TweetRepository with Cache::remember |
| Twig extension + partial | Blade component (or @include) |
Bundle Configuration validation |
config + a small DTO / validation at boot |
Repository with stale-on-error fallback. The bundle's cache layer is the one piece of engineering worth preserving — do it properly:
final class TweetRepository
{
public function __construct(private TwitterClient $client) {}
/** @return array<int, array{text:string,id:string,created_at:string}> */
public function latest(string $handle, int $count): array
{
$key = "last-tweets:{$handle}:{$count}";
$ttl = config('last-tweets.ttl');
$cached = Cache::get($key);
try {
$fresh = $this->client->fetch($handle, $count); // hard timeout inside
Cache::put($key, $fresh, $ttl);
return $fresh;
} catch (\Throwable $e) {
report($e); // alert, never surface to the page
return $cached ?? []; // stale-on-error, or empty
}
}
}
Bind it in a ServiceProvider, publish the config, and keep credentials in config/services.php + .env — never in a published config file that lands in git. This mirrors the bundle's multi-environment YAML setup without the Symfony plumbing.
Single-flight the refresh. The bundle's cache was adequate for a 2011 traffic profile; modern pages are spikier. Wrap the miss path in a short lock so N concurrent cold-cache requests produce one API call, not N:
return Cache::lock("last-tweets:{$handle}:refresh", 10)->block(3, function () use (...) {
// check cache again inside the lock, then fetch
});
Render defensively. Blade, not Twig, but the discipline transfers:
@if(count($tweets))
<ul class="tweets">
@foreach($tweets as $t)
<li>{{ $t['text'] }} <time>{{ $t['created_at'] }}</time></li>
@endforeach
</ul>
@endif
{{-- nothing rendered on empty/failed fetch — no broken box --}}
Note {{ }}, never {!! !!}. Tweet text, display names, and media URLs are untrusted input. Validate media URLs against expected hosts before linking.
Test with fakes, not the network. The bundle had no test story you can reuse; write your own:
Http::fake(['api.x.com/*' => Http::response($fixture, 200)]);
$this->get('/')->assertSee('expected tweet text');
Add cases for 401 (bad creds → stale cache), 429 (rate limit → stale cache + no in-request retry), and empty payload (renders nothing).
Feature-flag the rollout. Ship behind a flag, keep the old markup for one release, then cut over — the same sequencing the bundle would have needed, just owned by you.
The API is the first blocker, not the code. X's v1.1 timeline endpoint is retired. Before writing a single line of repository code, spend a timeboxed half-day confirming you have credentials at a tier that works from your infrastructure today, and that the endpoint and auth scheme you intend to use actually respond. This step invalidates the whole plan most often; sequence it first with a hard stop.
composer require will fail — by design. Symfony ^2.3 / PHP ~5.3|~7.0 constraints versus PHP 8.1+. Don't spend time trying to "make it install"; it's not a tuning problem, it's a hard mismatch. If a fork exists, assume the same.
PHP 8 deprecations even in a ported copy. The bundle predates PSR-12 and dynamic-property rules. If you lift any code, expect issues with dynamic properties, null-to-scalar coercion, and ${var} interpolation (removed in PHP 8.2). Rewrite rather than copy.
Escape on output, always — the bundle's template is not a safe template. The original partial may echo raw tweet text. That's an XSS vector the moment you rehost it. In Blade, {{ }} and a reviewed output path; audit any {!! !!} you find and never carry one over.
Cache TTL is a product decision, not a default. The bundle exposed it as config; keep it explicit. 5–15 minutes is sensible. Too long displays deleted/edited tweets (brand and legal exposure); too short approaches a live-API call per view. Provide a php artisan last-tweets:flush <handle> cache-bust command for the "retracted tweet is still showing" ticket — you will get it.
Never block a page render on a live API call. With a 5–15 minute cache the hit rate is trivially high and API volume is negligible regardless of traffic. Without a cache, every page view is an API call subject to rate limits and 429s. Put the cached fragment behind the app cache and, ideally, the CDN/edge so misses don't stack under a spike.
Design the failure modes explicitly — this is the part the bundle never solved well and the part that actually matters for page reliability:
Credentials live in services.php + .env. Never in a published config checked into git. The bundle's YAML-in-repo habit is exactly the pattern to unlearn.
The scaling answer is "don't." With the official embed, scaling is X's problem and your surface is one <script> tag. With a server-side build, scaling means cache hit rate and single-flight — solvable, but a permanent few-hours-per-quarter tax plus a reactive fix whenever X changes something. Decide embed-vs-build first; it's the difference between ~0.5 dev-days and ~4–6 dev-days plus recurring cost.
No upstream to escalate to. Archived, 32 stars, 0 dependents, no security advisories, no issue triage. If you adopt or fork, name an internal owner in the runbook and document the two tickets this feature actually generates: "widget is blank" and "widget shows old tweets."
Treat the score as metadata arithmetic. A composite around 0.81 and opportunity around 0.66 shouldn't override dispositive signals: archived, zero dependents, dead framework target, dead API. Use the bundle as a reference for the shape — fetch, cache, render in a template — and nothing else. Default to the official client-side embed; build a thin first-party Laravel package only when a real requirement forces server-side rendering, and never resurrect the bundle itself.
How can I help you explore Laravel packages today?