jbouzekri/phumbor-bundle
Symfony bundle integrating the Phumbor PHP client for Thumbor. Configure your Thumbor server and security key, then generate signed image URLs and use Twig helpers to resize, crop, and optimize images in templates. Simplifies wiring Phumbor services into Symfony apps for dynamic thumbnails.
PhumborBundle is a thin Symfony bridge around the 99designs/phumbor client for Thumbor. It does one job: read server + secret from config and register the client's URL builder as a container service. There is no image processing here — all resizing/cropping/signing happens on your Thumbor instance.
1. Install
composer require jbouzekri/phumbor-bundle
Version 4.0.0 adds Symfony 8 support and drops Symfony versions that are no longer maintained. Practically: 4.x targets Symfony 8 (which itself requires PHP 8.4). If you are still on Symfony 6.x/7.x, stay on the 3.x line and pin it explicitly:
composer require "jbouzekri/phumbor-bundle:^3.0" # older Symfony
composer require "jbouzekri/phumbor-bundle:^4.0" # Symfony 8
Symfony Flex registers Jbouzekri\PhumborBundle\PhumborBundle automatically. On a non-Flex app add it to config/bundles.php:
Jbouzekri\PhumborBundle\PhumborBundle::class => ['all' => true],
2. Configure
# config/packages/phumbor.yaml
phumbor:
server: '%env(THUMBOR_SERVER)%' # public URL of your Thumbor / CDN, no trailing slash
secret: '%env(THUMBOR_SECRET)%' # must match SECURITY_KEY in thumbor.conf
# .env
THUMBOR_SERVER=https://thumbor.example.com
THUMBOR_SECRET=change-me
3. Confirm what got registered
bin/console debug:config phumbor
bin/console debug:container phumbor
The bundle wires the client's Thumbor\Url\BuilderFactory. Depending on the bundle version the service id is phumbor.builder (or phumbor.factory on older tags), and it may or may not be aliased for autowiring. The version-proof approach is to alias it yourself:
# config/services.yaml
services:
Thumbor\Url\BuilderFactory:
alias: phumbor.builder
4. First use case — a controller
use Thumbor\Url\BuilderFactory;
final class ProductController
{
public function __construct(private BuilderFactory $factory) {}
public function show(): Response
{
$thumbnail = $this->factory
->url('https://cdn.example.com/products/42.jpg')
->resize(400, 300)
->toUrl();
return $this->render('product/show.html.twig', ['thumbnail' => $thumbnail]);
}
}
Where to look first
vendor/jbouzekri/phumbor-bundle/ — the whole bundle is a few files (DependencyInjection/, Resources/config/services.*). Five minutes of reading tells you the exact service id and argument list for your version. Do this first after a major bump: 4.0 is a support-window release, so the service definition is the thing to re-verify, not the API.resize, fitIn, crop, smartCrop, addFilter, toUrl, alignment helpers) lives there, not in the bundle docs.docker run -p 8888:8888 minimalcompact/thumbor then point THUMBOR_SERVER=http://localhost:8888.Don't sprinkle $factory->url(...) across controllers and templates. Presets keep dimensions and filter stacks consistent and put the "which Thumbor" decision in one place:
final class ThumborUrls
{
public function __construct(private BuilderFactory $factory) {}
public function thumb(string $source): string
{
return $this->factory->url($source)->resize(150, 150)->toUrl();
}
public function hero(string $source): string
{
return $this->factory->url($source)
->fitIn(1600, 900)
->addFilter('quality', 80)
->toUrl();
}
public function og(string $source): string
{
return $this->factory->url($source)->resize(1200, 630)->toUrl();
}
}
Inject ThumborUrls everywhere; the BuilderFactory stays an implementation detail of this one class. This wrapper is also what makes a future major-version bump (or a service-id change) a one-file edit.
The bundle is a container bridge — the presentation layer is yours to write:
final class ThumborExtension extends AbstractExtension
{
public function __construct(private ThumborUrls $urls) {}
public function getFunctions(): array
{
return [new TwigFunction('thumbor', [$this, 'render'])];
}
public function render(string $source, string $preset = 'thumb'): string
{
return $this->urls->{$preset}($source);
}
}
<img src="{{ thumbor(product.image, 'thumb') }}"
srcset="{{ thumbor(product.image, 'thumb') }} 400w,
{{ thumbor(product.image, 'hero') }} 1600w"
sizes="(max-width: 600px) 100vw, 800px"
alt="{{ product.name }}">
$url = $factory->url($source)
->resize(300, 200)
->addFilter('quality', 80)
->addFilter('format', 'webp') // filter availability depends on the Thumbor build
->toUrl();
Filters such as format, background_color, sharpen, and fill are supplied by Thumbor itself (or thumbor-plugins). If a filter name isn't recognised, Thumbor answers 400, not the bundle — test each filter you introduce against the real server, especially after upgrading Thumbor.
The builder takes whatever URL string you give it and signs it. Decide once whether you pass relative paths, absolute CDN URLs, or storage-loader paths (s3://…), and convert at a single boundary (a SourceUrlResolver service). Mixing conventions is the most common source of broken images.
The bundle supports one server/secret pair. If you need a second (regional CDN, separate secret per tenant), build a factory by hand rather than fighting the config:
$eu = new BuilderFactory('https://eu.thumbor.example.com', $euSecret);
Then register that as a named service and select between them in your wrapper.
Point dev at a local/insecure Thumbor and prod at the CDN host. Keep the secret in a real secret store in prod; in dev an empty key is fine only if your Thumbor has unsafe URLs enabled — otherwise generate a throwaway key for the container too.
When moving to 4.0, do it as a two-step: bump Symfony to 8 (and PHP to 8.4) first, with the bundle still on 3.x if it allows, then bump the bundle. That way a BuilderFactory that fails to resolve is obviously attributable to the bundle, not the framework.
URL generation is pure string work, so assertions are straightforward:
self::assertStringContainsString('/400x300/', $urls->thumb($source));
For unit tests of services that consume ThumborUrls, mock the wrapper — not BuilderFactory — so you never depend on signature bytes. A single integration test in CI that boots the container and resolves Thumbor\Url\BuilderFactory is the cheapest guard against a service-id regression across bundle majors.
symfony/* constraints and your PHP version before bumping, and check that nothing else in your dependency tree pins jbouzekri/phumbor-bundle to ^3. Composer will otherwise resolve to 3.x and you'll conclude the release "did nothing".phumbor.builder / phumbor.factory naming has varied across tags before, and DependencyInjection/ is the only place that knows. Keep the explicit BuilderFactory alias in config/services.yaml so autowiring survives whatever the bundle does internally.debug:container --show-private is gone. On Symfony 8 private services are listed by default, so bin/console debug:container phumbor is enough. Old runbooks that paste --show-private will now error out.403 from Thumbor with an "invalid security key" log line. Confirm phumbor.secret and SECURITY_KEY in thumbor.conf are byte-identical; restart the container after changing it./unsafe/… paths. The signed-URL builder won't emit them. Configuring a key on both sides is simpler than trying to force unsafe mode.server yield https://host//… and confusing 404s. Store the URL without a trailing slash, and include any reverse-proxy path prefix (https://host/thumbor) since it becomes part of the signed path.THUMBOR_SERVER/THUMBOR_SECRET in a deployed environment, run bin/console cache:clear (or warm the cache in the deploy) before concluding "it didn't pick it up." Symfony 8 makes no difference here, but the major bump is a good moment to confirm your deploy actually warms the cache.http://localhost/... inside a Dockerised Thumbor resolves to the container itself. Use a public URL, a shared volume with a file loader, or host.docker.internal.toUrl() does an HMAC per call. Cheap, but in loops over thousands of entities it adds up; cache generated URLs per request or per preset when rendering large grids.BuilderFactory type-hints fail, add the alias shown above rather than injecting ContainerInterface.phumbor.builder unless you have to. For extra config keys, extend the bundle's Configuration only as a last resort — a wrapper service is cleaner and survives bundle upgrades.composer.json so an upstream change can't silently alter the service id.How can I help you explore Laravel packages today?