leaseweb/remote-template-bundle
Symfony bundle that loads Twig templates from remote sources rather than the local filesystem, so views can be managed and shared across services without redeploying. Handy for centralized template storage, multi-tenant apps, and CMS-driven content.
Day one reality check: this is a Symfony2 bundle. composer require leaseweb/remote-template-bundle in a Laravel app will fail on PHP constraint and symfony/* component pins, and even if it installed there is no bundle-registration path. You are reading it, not depending on it. The repository is archived with zero dependents, so treat it as a reference for the loader pattern and budget the afternoon you'd spend skimming it as the entire "installation" step.
Where to look first (30 minutes):
Twig_LoaderInterface (getSource, getCacheKey, isFresh, exists). This is the whole pattern; everything else is DI plumbing.DependencyInjection extension — see how the loader is constructed and where the remote origin/base URL is configured.Bundle class — confirms the Symfony2-era API surface and that nothing here transfers.Ignore the service definitions and config YAML beyond confirming what a "remote source" is modelled as (a base URL plus a path, most likely).
Your first Laravel-side use case: a single, low-risk, first-party marketing landing page rendered from a namespace that is populated by a sync command — not a fetch on the request path.
php artisan make:command TemplatesSync
# pulls https://themes.internal/acme/v42/landing.blade.php over HTTPS,
# verifies sha256 against a signed manifest, writes atomically to
# storage/app/templates/acme/v42/landing.blade.php
Register it once in a service provider:
public function boot(): void
{
$version = Cache::rememberForever('templates.acme.version', fn () => file_get_contents(
storage_path('app/templates/acme/LATEST')
));
View::addNamespace('acme', storage_path("app/templates/acme/{$version}"));
}
Then return view('acme::landing');. That is the smallest shape of the capability, and it has no network call in the render path.
Config you actually need: allowlisted origins, a local cache/artifact directory, a signing key or pinned hash manifest, a TTL/version pointer, and a boolean kill switch defaulted to off.
Pattern A — build-time sync (default choice). An Artisan command fetches, verifies, and materialises templates into a versioned directory, which is registered as a view namespace. Rendering is entirely local, identical across instances, diffable in review, and deployable as an artifact. This covers ~80% of "marketing needs to change a page without a release" because you can run the sync in CI on a schedule.
// inside the command
$resp = Http::withOptions(['allow_redirects' => false])
->timeout(10)->connectTimeout(3)
->withHeaders(['If-None-Match' => $etag])
->get($url);
abort_unless($resp->successful(), 500);
abort_unless(hash_equals($expectedSha, hash('sha256', $resp->body())), 500);
$tmp = $target.'.tmp.'.Str::random(8);
Storage::put($tmp, $resp->body());
Storage::move($tmp, $target); // atomic rename
Pattern B — runtime fetch only on cache miss, fail closed.
If a genuine runtime requirement survives scrutiny, subclass Illuminate\View\FileViewFinder (or wrap it) so that a miss in the namespace triggers a cached fetch plus a hard fallback to a bundled template. Keep it behind a config flag and never make it the global finder.
class RemoteAwareFinder extends FileViewFinder
{
protected function findNamespacedView($namespace, $name): string
{
try { return parent::findNamespacedView($namespace, $name); }
catch (InvalidArgumentException $e) {
return $this->fetchAndCache($namespace, $name); // throws if unfetchable
}
}
}
Pattern C — per-tenant namespaces.
View::addNamespace("tenant.{$id}", $path) with a version pointer resolved per tenant. This is the multi-brand story: each tenant gets an isolated directory and an isolated version, so a bad publish for tenant A cannot affect tenant B. Resolve the pointer from cache, not from a glob() in boot().
Integration tips that transfer from the original package:
isFresh/getCacheKey intuition: your version pointer should be part of the path so Blade's compiled-view cache naturally invalidates. Versioned paths beat mtime checks.templates.fallback.count) — silent fallbacks are how a broken origin goes unnoticed for a week.Storage::fake() in tests; assert the command fails closed on hash mismatch and on non-2xx.Remote Blade is remote PHP. Twig's sandbox has no Blade equivalent. A remotely-supplied .blade.php is executable code with your full application context, and even @include('layouts.app') from a tenant template reaches local views. This is the reason the pattern is only safe for first-party, signed artifacts. If the source is tenant- or partner-supplied, stop and use a logic-less format (Mustache, or a JSON layout schema rendered by fixed components) instead.
Do not try to install the package. Composer will hard-fail on PHP and symfony/* conflicts, and there is no port. Any time spent attempting it is wasted; any code you do write shares nothing with upstream and inherits none of its (unverified) hardening.
Atomicity. A half-written template file gets compiled into a storage/framework/views entry, and the resulting parse error points at generated PHP, not at your sync command. Always write-temp-then-rename, and never write directly to the target on a retry.
Blade's compiled-view cache is path-keyed. If you overwrite the same path in place, the compiled view may not refresh. Version the directory (acme/v42/) and update a tiny LATEST pointer file, or run php artisan view:clear in the deploy. Orphaned compiled views accumulate; prune storage/framework/views periodically.
view:cache and config:cache in the deploy pipeline. php artisan view:cache will attempt to compile every registered namespace. If the sync hasn't run yet (or runs on a different instance), the build fails with an obscure missing-view error. Order the pipeline: sync → verify → view:cache, and exclude remote namespaces from precompilation if sync is per-host.
SSRF hardening if you do fetch at runtime. allow_redirects => false, scheme allowlist (https only), host allowlist checked after DNS resolution, verify => true, explicit connectTimeout, and a body-size limit. A decade-old bundle almost certainly has none of these; do not copy its HTTP layer.
Fail closed, but make the failure legible. On hash mismatch or unfetchable origin, throw with the origin, expected hash, and actual hash in the message. Never fall back to unverified content, and never fall back silently to a stale unverified copy.
Namespace collisions. View::addNamespace overwrites; if two packages both claim acme, the last one in boot() wins. Prefix with your app (app.acme) or centralise namespace registration in one provider.
Symfony2-specific, if you inherit a legacy app: the loader is bound as a service ID and consumed by the Twig loader chain; rebinding it globally is how you accidentally route all template resolution through the network. Scope it with a dedicated filesystem loader in front. Upstream is archived — assume no security response and pin the commit hash if you must run it.
How can I help you explore Laravel packages today?