poli-page / symfony
Symfony bundle for the Poli Page PDF rendering API
Package info
Type:symfony-bundle
pkg:composer/poli-page/symfony
Requires
- php: ^8.3
- nyholm/psr7: ^1.8
- poli-page/sdk: ^0.11.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^3.0
- symfony/config: ^6.4 || ^7.0
- symfony/console: ^6.4 || ^7.0
- symfony/dependency-injection: ^6.4 || ^7.0
- symfony/event-dispatcher: ^6.4 || ^7.0
- symfony/http-client: ^6.4 || ^7.0
- symfony/http-foundation: ^6.4 || ^7.0
- symfony/http-kernel: ^6.4 || ^7.0
Requires (Dev)
- dg/bypass-finals: ^1.5
- friendsofphp/php-cs-fixer: ^3.64
- guzzlehttp/psr7: ^2.6
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^11.0
- symfony/browser-kit: ^6.4 || ^7.0
- symfony/framework-bundle: ^6.4 || ^7.0
- symfony/phpunit-bridge: ^7.0
- symfony/yaml: ^6.4 || ^7.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-11-02 07:35:00 UTC
README
Render Poli Page documents as Symfony controller responses.
About
This bundle wires the Poli Page PHP SDK into Symfony's container, EventDispatcher, and logger. You autowire PoliPage into any service or controller, return PDFs through PoliPageResponseFactory, configure everything in config/packages/poli_page.yaml, and dispatch the SDK's retry/error hooks as Symfony events you subscribe to with #[AsEventListener].
When to use this:
- You want an autowireable
PoliPageclient that follows your service graph. - You want the SDK's
onRetry/onErrorhooks delivered through the EventDispatcher. - You want a console command to verify your API key and connectivity from CI.
When not to:
- You don't use Symfony — install
poli-page/sdkdirectly. - You need to reimplement transport, retry, or error mapping — that belongs in the SDK, not in a bundle on top of it.
Requirements
- PHP 8.3+
- Symfony 6.4 LTS or 7.x
poli-page/sdk0.11.0 or later (Composer installs it with the bundle)- A Poli Page API key from app.poli.page
Install
composer require poli-page/symfony
Register the bundle in config/bundles.php:
return [ // ... PoliPage\Symfony\PoliPageBundle::class => ['all' => true], ];
Create config/packages/poli_page.yaml (every option is listed under Configuration):
poli_page: api_key: '%env(POLI_PAGE_API_KEY)%'
and add POLI_PAGE_API_KEY= to .env. Once the bundle's Flex recipe is accepted in symfony/recipes-contrib, composer require will do these three steps for you.
Set your key in .env.local. Each key belongs to one environment of your organization, named in the key:
| Environment | Key | Watermark | Counted |
|---|---|---|---|
sandbox (every org) |
pp_sandbox_… |
yes | no |
live (every org, deletable on Pro+) |
pp_live_… |
no | yes |
created on Pro+ (e.g. staging) |
pp_staging_… |
no | yes |
"Counted" means the render counts toward the organization's monthly PDF quota. A render uses the project's copy published in the key's environment; there is no template version to pick.
POLI_PAGE_API_KEY=pp_sandbox_your_key_here
Verify everything is wired end-to-end:
bin/console poli-page:render \
--project=getting-started \
--template=welcome \
--data='{"name":"World"}' \
-o welcome.pdf
Quick start
// src/Controller/InvoiceController.php namespace App\Controller; use PoliPage\PoliPage; use PoliPage\RenderInput; use PoliPage\Symfony\Http\PoliPageResponseFactory; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; final class InvoiceController { public function __construct( private readonly PoliPage $poliPage, private readonly PoliPageResponseFactory $factory, ) {} #[Route('/invoice/{id}', methods: ['GET'])] public function show(string $id): Response { $pdf = $this->poliPage->render->pdf(new RenderInput( project: 'invoices', template: 'default', data: ['invoice_id' => $id], )); return $this->factory->bytes($pdf, "invoice-{$id}.pdf"); } }
Configuration
| Option | Default | Description |
|---|---|---|
api_key |
required | API key, pp_{environment}_… (see the key table above). Any non-empty value is accepted and the API decides; a value that does not look like pp_{environment}_{32 hex} logs one warning when the client is built. The warning names the expected format and shows no part of the key. |
base_url |
SDK default | Override the API origin (must be http/https). |
timeout |
SDK default | Per-request timeout in seconds (0 < t ≤ 600). |
retries.max_attempts |
SDK default | Retry budget (0–10). |
retries.delay_seconds |
SDK default | Base delay before the first retry (0–30s). |
http_client |
symfony/http-client |
PSR-18 service id. |
request_factory |
nyholm/psr7 |
PSR-17 request-factory service id. |
stream_factory |
nyholm/psr7 |
PSR-17 stream-factory service id. |
logger |
logger |
PSR-3 service id. |
on_retry |
none | Service implementing __invoke(RetryEvent). |
on_error |
none | Service implementing __invoke(PoliPageException). |
exception_listener.enabled |
true |
Answers an uncaught PoliPageException with its toPayload() JSON body and status (see Errors). Set to false to handle it yourself. |
# config/packages/poli_page.yaml poli_page: api_key: '%env(POLI_PAGE_API_KEY)%' timeout: 30 retries: max_attempts: 3
You inspect the resolved config with bin/console debug:config poli_page.
API at a glance
| Symbol | Purpose |
|---|---|
PoliPage\PoliPage |
Autowired SDK client (->render, ->documents). |
PoliPage\Symfony\Http\PoliPageResponseFactory |
Builds bytes / stream / preview (a stored document's HTML, DocumentPreviewResult) / documentRedirect responses with the right headers. |
PoliPage\Symfony\Event\PoliPageRetryEvent |
Dispatched when the SDK retries a request. |
PoliPage\Symfony\Event\PoliPageErrorEvent |
Dispatched when the SDK raises a PoliPageException. |
PoliPage\Symfony\PoliPageBundle |
The bundle class registered in config/bundles.php. |
poli-page:render |
Console command that renders a template end-to-end. |
Full reference: docs.poli.page.
Errors
Exception listener
The bundle registers a kernel.exception listener, on by default. A PoliPage\PoliPageException that your controller does not catch becomes a JSON answer built from the SDK's toPayload():
{ "code": "TEMPLATE_NOT_FOUND", "message": "Template 'does-not-exist' not found in project 'getting-started'.", "status": 404, "requestId": "req_…" }
- The HTTP status is the payload's
status: the API's status (404, 429, 500, …),503for anetwork_errorand504for atimeout. An SDK error with no status (for exampleinvalid_options) answers500, with"status": nullin the body. requestIdis the API'sx-request-id, ornullwhen there is none.- The
Content-Typeisapplication/json, withCache-Control: no-store, private. The listener runs at priority-10, so Symfony still logs the exception first, and a listener of yours with a higher priority can answer instead. - If the request's response already started, the listener writes nothing more: the client sees a truncated download rather than JSON appended to the PDF. That is the case when a
StreamedResponsecallback throws (a streamed PDF whose download fails midway) and, on Symfony 7.1+, when akernel.terminatelistener throws. The check is made per response, not withheaders_sent(), so in a long-running worker (RoadRunner, FrankenPHP worker mode) output sent earlier in the process does not change how a later request's error is answered. - For a streamed PDF, call
render->pdfStream()in the controller and pass its stream toPoliPageResponseFactory::stream(), which reads the first chunk before it returns. A render or download error then reaches the listener before any PDF header is sent. Calling the SDK inside your ownStreamedResponsecallback would commit a200 application/pdffirst.
To handle SDK errors yourself, turn it off:
# config/packages/poli_page.yaml poli_page: exception_listener: enabled: false
Exception types
The SDK throws PoliPage\PoliPageException (or a subclass). Catch them in your controller where you want a different answer than the listener's. The four categories you typically discriminate on:
- Auth —
PoliPage\Exception\AuthenticationException. Invalid or missing API key. - Rate limit —
PoliPage\Exception\RateLimitException. You hit the rate limit; retry perRetry-After. - Request rejected —
PoliPage\Exception\BadRequestException. Template or data rejected by the API. - Network / transport —
PoliPage\Exception\ConnectionException(includesTimeoutException). Connection failure, DNS, TLS, or timeout.
use PoliPage\Exception\AuthenticationException; use PoliPage\Exception\BadRequestException; use PoliPage\Exception\ConnectionException; use PoliPage\Exception\RateLimitException; use PoliPage\PoliPageException; try { $pdf = $this->poliPage->render->pdf($input); } catch (AuthenticationException $e) { // re-check POLI_PAGE_API_KEY throw $e; } catch (RateLimitException $e) { // honour $e->retryAfter throw $e; } catch (BadRequestException $e) { // template/data rejected — surface $e->errorCode to the user throw $e; } catch (ConnectionException $e) { // network/timeout — safe to retry the whole request throw $e; } catch (PoliPageException $e) { // anything else from the SDK throw $e; }
Example app
A runnable Symfony 7 app exercising every public method of the SDK lives in example-app/. It exposes an interactive dashboard at http://127.0.0.1:8000/ with one button per SDK feature, plus the underlying JSON / PDF routes for scripted use (GET /render/pdf, GET /documents/{id}, GET /errors/not-found, …).
cd example-app composer install composer serve # → http://127.0.0.1:8000
Compatibility
| Bundle | Symfony | PHP |
|---|---|---|
| 0.11.x | 6.4 LTS / 7.x | 8.3 – 8.4 |
Supported Symfony lines follow the upstream LTS schedule.
Contributing
composer install
composer ci # php-cs-fixer, PHPStan and the unit tests
Contract tests
tests/Contract/ImplContractTest.php runs the integration cases of the Poli Page API contract (impl.* in poli-page/api-contract) against the example app, with its SDK pointed at a local mock API. CI runs them against the contract version pinned in .github/workflows/ci.yml. Locally:
POLI_CONTRACT=/path/to/contract.json vendor/bin/phpunit --testsuite=contract
Without POLI_CONTRACT the suite is skipped (it fails when CI is set). A case that must fail for now goes in tests/contract-skip.json with its reason.
License
Released under the MIT License.