Search by

poli-page / symfony

poli-page

Symfony bundle for the Poli Page PDF rendering API

Package info

github.com/poli-page/symfony

Documentation

Type:symfony-bundle

pkg:composer/poli-page/symfony

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 10

dev-main 2026-11-02 07:30 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 PoliPage client that follows your service graph.
  • You want the SDK's onRetry / onError hooks 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/sdk directly.
  • 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/sdk 0.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, …), 503 for a network_error and 504 for a timeout. An SDK error with no status (for example invalid_options) answers 500, with "status": null in the body.
  • requestId is the API's x-request-id, or null when there is none.
  • The Content-Type is application/json, with Cache-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 StreamedResponse callback throws (a streamed PDF whose download fails midway) and, on Symfony 7.1+, when a kernel.terminate listener throws. The check is made per response, not with headers_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 to PoliPageResponseFactory::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 own StreamedResponse callback would commit a 200 application/pdf first.

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 per Retry-After.
  • Request rejected — PoliPage\Exception\BadRequestException. Template or data rejected by the API.
  • Network / transport — PoliPage\Exception\ConnectionException (includes TimeoutException). 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.