psliwa/php-pdf
Pure-PHP library for generating PDF documents. Build pages with text, images, shapes, colors, and barcodes; supports fonts, transformations, and simple drawing APIs. Useful for reports, invoices, and dynamic PDFs without external binaries.
Gate on the environment before you write a line of code. This is a Composer install plus a native extension plus a PHP compatibility question, and any of the three can kill the spike in an hour.
# 1. Does it even install against your PHP?
composer require psliwa/php-pdf --dry-run
# 2. Is Imagick present in the runtime that will actually render?
php -m | grep -i imagick
php -r 'var_dump(extension_loaded("imagick") ? Imagick::getVersion() : null);'
If Composer resolves, immediately pin the exact installed version in composer.json and commit composer.lock. Do not use a caret constraint — upstream is dormant and a stray composer update is a production incident.
Where to look first, in order:
examples/ in the vendor directory — these are the real documentation. There is no Laravel-facing docs, no service provider, no config publishing.document.xml in examples) and the accompanying stylesheet. That pairing is the whole mental model: structure in XML, presentation in a CSS-like file.Psliwa\Pdf\Builder\PdfBuilder — the programmatic builder, if you prefer to construct pages in PHP instead of XML.PHPPdf\Core\FacadeBuilder — the thin entry point most examples use for file-to-file rendering.Minimal first render (file-to-file, the fastest path to "it works"):
use PHPPdf\Core\FacadeBuilder;
$facade = FacadeBuilder::create()->build();
$facade->createPdf(
resource_path('pdf/invoice.xml'),
storage_path("app/pdf/invoices/{$invoice->id}.pdf"),
resource_path('pdf/invoice.css'),
'utf-8'
);
Start with a hardcoded two-line invoice template. Confirm the PDF opens in a viewer, then confirm it opens in your actual target consumer (email clients, accounting exports, print shops) — visual acceptance is the real test, not file_exists().
First real use case: the invoice or receipt. It is the document with the highest business value, the lowest layout complexity, and the clearest success criteria. Resist starting with the 200-line transaction statement; get one page right before you test auto-pagination.
Do not trust the repository description. It advertises reading and parsing existing PDFs and text extraction. The library is overwhelmingly a generator with an XML/CSS layout DSL. If you need parsing or text extraction, reach for smalot/pdfparser and keep this package focused on creation.
Wrap it behind a contract from day one. The library is framework-agnostic, unmaintained, and you will likely replace it. One interface, one adapter, zero direct PHPPdf\* references in controllers or models.
// app/Contracts/PdfRenderer.php
namespace App\Contracts;
interface PdfRenderer
{
/** @param array<string, mixed> $data */
public function render(string $template, array $data): string; // returns PDF bytes
}
// app/Services/PhpPdfRenderer.php
namespace App\Services;
use App\Contracts\PdfRenderer;
use PHPPdf\Core\FacadeBuilder;
use Illuminate\Support\Facades\Storage;
class PhpPdfRenderer implements PdfRenderer
{
public function render(string $template, array $data): string
{
$xml = view("pdf.{$template}.xml", $data)->render();
$css = Storage::disk('pdf')->get("styles/{$template}.css");
$tmpXml = tempnam(sys_get_temp_dir(), 'pdf_xml_');
$tmpOut = tempnam(sys_get_temp_dir(), 'pdf_out_');
try {
file_put_contents($tmpXml, $xml);
FacadeBuilder::create()
->build()
->createPdf($tmpXml, $tmpOut, /* stylesheet path or contents */);
return file_get_contents($tmpOut);
} finally {
@unlink($tmpXml);
@unlink($tmpOut);
}
}
}
Bind it in a service provider: $this->app->bind(PdfRenderer::class, PhpPdfRenderer::class);.
Keep templates in Blade, not as static XML. You get escaping, loops, and partials for free — and it solves the number-one correctness bug (unescaped & and < in customer names breaking the XML parser).
{{-- resources/views/pdf/invoice/xml.blade.php --}}
<document>
<page>
<text font-size="18" font-weight="bold">{{ $invoice->number }}</text>
<text>Billed to: {{ $invoice->customer->name }}</text>
@foreach ($invoice->lines as $line)
<text>{{ $line->description }}</text>
<text align="right">{{ number_format($line->amount, 2) }}</text>
@endforeach
</page>
</document>
Render out-of-band, always. Never build a PDF in the request cycle beyond a trivial page. Push to a queued job with explicit limits:
class GenerateInvoicePdf implements ShouldQueue
{
public int $timeout = 120;
public int $tries = 3;
public function handle(PdfRenderer $renderer): void
{
ini_set('memory_limit', '512M');
$bytes = $renderer->render('invoice', ['invoice' => $this->invoice]);
// atomic write: temp then move, never stream directly to the final disk path
Storage::disk('invoices')->put(
"{$this->invoice->id}.pdf",
$bytes
);
}
}
Use a dedicated queue (->onQueue('pdf')) so a memory-hungry render cannot starve general workers.
Model long documents explicitly. The library's differentiator is flowing multi-page output with repeated headers and footers, but you must give it structure: a page node with auto-height, breakable content, and a footer template with a page-number placeholder. Test with a 200-row table on day one — that is the fixture that exposes pagination bugs.
Add a second adapter immediately — MpdfRenderer or BrowsershotRenderer implementing the same contract. It costs an hour, it gives you an A/B benchmark, and it means the exit ramp exists before you are committed.
Golden-file tests in CI. Visual DSLs regress silently. Render each supported template from fixed fixtures and either snapshot a hash or rasterise page 1 with Imagick and compare against a committed PNG with a small pixel tolerance. Text-based assertions alone will miss the failure modes that matter here.
Resolve fonts and images explicitly. Route every asset path through Storage::disk(...)->path() or a configured absolute path in config/pdf.php. Never rely on the library's default working-directory assumptions; they differ between CLI, FPM, and queue workers.
Class 'Imagick' not found fatal, not a catchable exception. Add a boot-time health check (a health endpoint or a queued VerifyPdfRenderer job) so you learn about it at deploy, not at 2 a.m.repositories entry pointing at your VCS fork so patches ship on your schedule. Budget an ongoing owner — an unowned fork is worse than no dependency.&, <, ", or CJK characters corrupt the document or the layout. Blade's {{ }} handles the escaping; free-form string concatenation does not.resources/pdf/cookbook.md with working snippets — this knowledge is non-transferable and leaves with the developer.Imagick::setRegistry('temporary-path', ...) or ensure per-worker scratch space. Write output to a unique temp file then rename — partial PDFs from timed-out jobs are worse than failures.Exception from a queue worker with a truncated stack trace is the usual debugging experience.<total> node). Prefer extending the node system over post-processing the generated PDF.How can I help you explore Laravel packages today?