FormaTeX

\begin{article}

LaTeX PDF API in PHP and Laravel

Integrate a LaTeX compilation API in PHP and Laravel to generate professional PDFs without installing TeX Live on your server.

·10 min read·
LaTeX PDF API in PHP and Laravel

If you have ever tried to generate a PDF from LaTeX inside a PHP application, you know the pain: installing a 4 GB TeX Live distribution on your server, managing PATH variables, spawning shell processes, and cleaning up temp files manually — all before writing a single line of business logic. FormatEx eliminates every step of that. You POST LaTeX source to an API endpoint, and you get a compiled PDF back. This tutorial shows you exactly how to do that in plain PHP with Guzzle and in Laravel using the built-in HTTP client.

Why Not Just Run pdflatex on Your Server?

The naive approach is to shell out from PHP:

php
<?php
$latex = file_get_contents('/tmp/document.tex');
exec('pdflatex -output-directory /tmp /tmp/document.tex', $output, $exitCode);
if ($exitCode !== 0) {
    throw new RuntimeException('pdflatex failed');
}
$pdf = file_get_contents('/tmp/document.pdf');

This works on a local dev machine. In production it creates a list of problems:

  • TeX Live is enormous. A full install exceeds 4 GB. Even a minimal install that covers common packages is several hundred megabytes and breaks on first use when a missing package causes a cryptic error at runtime. See why TeX Live Docker images are 4 GB for a deeper breakdown.
  • Security surface. Passing user-supplied content to a shell command requires careful sanitization. A single unescaped path component can be a code-execution vulnerability.
  • No isolation. A runaway LaTeX job can consume all CPU on the server, affecting every other request.
  • Ephemeral environments. Serverless functions, containers with read-only filesystems, and PaaS platforms like Laravel Vapor do not have pdflatex available and cannot write to /tmp reliably.
  • Version drift. TeX Live updates change package behavior. Your documents compile differently in CI, staging, and production.

A remote compilation API removes all of these concerns. The TeX infrastructure lives elsewhere. Your server sends JSON and receives a PDF.

FormatEx API Basics

Before writing code, here is what you are working with:

DetailValue
Endpointhttps://api.formatex.io/api/v1/compile
MethodPOST
Auth headerX-API-Key: YOUR_KEY
Content typeapplication/json
Success responseRaw PDF binary (application/pdf)
Error responseJSON { "error": "...", "log": "..." } with status 422

The engine field accepts pdflatex, xelatex, lualatex, or latexmk. The Free plan supports pdflatex and gives you 15 compilations per month — enough to build and test an integration. Paid plans start at $12/month and unlock the other engines and higher limits.

Get your first API key at formatex.io — the getting started guide walks through sign-up, key creation, and your first compilation in under five minutes.

Calling the API with Plain PHP and Guzzle

Install Guzzle if you have not already:

bash
composer require guzzlehttp/guzzle

Here is a minimal wrapper class that covers the common case — sending LaTeX source and receiving PDF bytes:

php
<?php

namespace App\Pdf;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use RuntimeException;

class FormatExClient
{
    private Client $http;
    private string $apiKey;

    public function __construct(string $apiKey)
    {
        $this->apiKey = $apiKey;
        $this->http = new Client([
            'base_uri' => 'https://api.formatex.io',
            'timeout'  => 60.0,
        ]);
    }

    /**
     * Compile LaTeX source and return raw PDF bytes.
     *
     * @throws RuntimeException on compilation failure
     */
    public function compile(string $latex, string $engine = 'pdflatex'): string
    {
        try {
            $response = $this->http->post('/api/v1/compile', [
                'headers' => [
                    'X-API-Key'    => $this->apiKey,
                    'Content-Type' => 'application/json',
                    'Accept'       => 'application/pdf',
                ],
                'json' => [
                    'latex'  => $latex,
                    'engine' => $engine,
                ],
            ]);

            return (string) $response->getBody();

        } catch (ClientException $e) {
            $body = json_decode((string) $e->getResponse()->getBody(), true);
            $log  = $body['log'] ?? $body['error'] ?? 'Unknown compilation error';
            throw new RuntimeException("LaTeX compilation failed:\n" . $log);
        }
    }
}

Usage:

php
<?php

require 'vendor/autoload.php';

$client = new App\Pdf\FormatExClient($_ENV['FORMATEX_API_KEY']);

$latex = <<<'LATEX'
\documentclass{article}
\begin{document}
\title{Invoice \#1042}
\author{Acme Corp}
\maketitle
\section*{Summary}
Services rendered: \$1{,}200.00
\end{document}
LATEX;

$pdf = $client->compile($latex);

header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="invoice-1042.pdf"');
header('Content-Length: ' . strlen($pdf));
echo $pdf;

The RuntimeException message includes the raw TeX compiler log, which tells you the exact line and error — the same output you would see running pdflatex locally.

Laravel Integration with the HTTP Client

Laravel ships with a fluent HTTP client built on Guzzle. You do not need to add a dependency or write a low-level wrapper. Here is a service class that fits cleanly into a Laravel application:

php
<?php

namespace App\Services;

use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;

class LatexPdfService
{
    public function compile(string $latex, string $engine = 'pdflatex'): string
    {
        $response = Http::withHeaders([
                'X-API-Key' => config('services.formatex.key'),
                'Accept'    => 'application/pdf',
            ])
            ->timeout(60)
            ->post('https://api.formatex.io/api/v1/compile', [
                'latex'  => $latex,
                'engine' => $engine,
            ]);

        if ($response->failed()) {
            $json = $response->json();
            $log  = $json['log'] ?? $json['error'] ?? 'Compilation error';
            throw new RuntimeException("LaTeX compilation failed:\n" . $log);
        }

        return $response->body();
    }
}

Register your API key in config/services.php:

php
'formatex' => [
    'key' => env('FORMATEX_API_KEY'),
],

And add FORMATEX_API_KEY=your_key_here to your .env file.

Now inject the service wherever you need PDF generation:

php
<?php

namespace App\Http\Controllers;

use App\Services\LatexPdfService;
use App\Models\Invoice;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use RuntimeException;

class InvoicePdfController extends Controller
{
    public function __construct(private LatexPdfService $latex) {}

    public function download(Request $request, Invoice $invoice): Response
    {
        $this->authorize('view', $invoice);

        $latex = view('pdfs.invoice', ['invoice' => $invoice])->render();

        try {
            $pdf = $this->latex->compile($latex);
        } catch (RuntimeException $e) {
            report($e);
            abort(500, 'PDF generation failed. Please try again.');
        }

        return response($pdf, 200, [
            'Content-Type'        => 'application/pdf',
            'Content-Disposition' => 'attachment; filename="invoice-' . $invoice->id . '.pdf"',
        ]);
    }
}

The view('pdfs.invoice') call renders a Blade template containing your LaTeX source. Keeping LaTeX in a Blade file means you can use {{ $invoice->total }}, @foreach, and all other Blade directives to build dynamic documents while keeping the template readable.

Generating Invoices and Reports

A typical LaTeX PDF invoice Blade template at resources/views/pdfs/invoice.blade.php:

text
\documentclass[11pt]{article}
\usepackage[margin=2.5cm]{geometry}
\usepackage{booktabs}
\usepackage{array}
\usepackage{eurosym}

\begin{document}

\begin{flushright}
  \textbf{ {{ $invoice->company_name }} }\\
  {{ $invoice->company_address }}
\end{flushright}

\vspace{1em}
\textbf{Invoice \#{{ $invoice->number }}} \hfill \textbf{Date:} {{ $invoice->date->format('d M Y') }}

\vspace{1.5em}
\begin{tabular}{p{8cm} r r}
  \toprule
  \textbf{Description} & \textbf{Qty} & \textbf{Amount} \\
  \midrule
  @foreach($invoice->items as $item)
  {{ $item->description }} & {{ $item->quantity }} & \euro{{ number_format($item->amount, 2) }} \\
  @endforeach
  \midrule
  \multicolumn{2}{r}{\textbf{Total}} & \euro{{ number_format($invoice->total, 2) }} \\
  \bottomrule
\end{tabular}

\end{document}

A few things to keep in mind when building LaTeX templates with dynamic data:

  1. Escape special characters. LaTeX treats &, %, $, #, _, {, }, ~, ^, and \ as control characters. Create a helper that escapes user-supplied strings before inserting them into the template.
  2. Use xelatex for non-ASCII text. If your invoices include accented characters, Arabic, Chinese, or any non-Latin script, switch engine to xelatex and add \usepackage{fontspec} to your preamble. See the XeLaTeX vs pdfLaTeX comparison for details on Unicode support and custom fonts. This requires a Developer plan or higher.
  3. Keep templates in version control. LaTeX templates are code. Treat them like Blade views — review changes before deploying.
  4. Test with the Playground first. Before wiring up a template to production data, paste a rendered version into the FormatEx Playground to verify it compiles cleanly.

Handling Errors Correctly

LaTeX compilation errors are normal — a missing brace or an undefined command causes the compiler to abort. The FormatEx API returns a 422 status and a JSON body that includes the full TeX log. The complete API error codes guide covers every status code, including 429 rate-limit responses and how to distinguish them from compilation failures.

json
{
  "success": false,
  "error": "compilation failed",
  "log": "! Undefined control sequence.\nl.14 \\euro\n..."
}

In a Laravel application, the correct pattern is:

  • Catch the RuntimeException at the controller level.
  • Log the full TeX log via report() so it appears in your log aggregator.
  • Show the user a friendly message — not the raw TeX log.
  • In development, you may want to display the log directly to speed up debugging.
php
try {
    $pdf = $this->latex->compile($latex);
} catch (RuntimeException $e) {
    // Full log goes to your log aggregator
    report($e);

    if (app()->isLocal()) {
        // Show the raw TeX log in development
        abort(500, $e->getMessage());
    }

    abort(500, 'PDF generation failed.');
}

For background jobs (queued invoice generation, bulk report exports), wrap the compilation in a try/catch inside your handle() method and push a failed notification or update a database status field rather than letting the exception bubble up and retry indefinitely.

Queuing PDF Generation in Laravel

For documents that take more than a couple of seconds — multi-page reports, documents with many bibliography entries compiled via latexmk — offload the compilation to a queue. This async compilation and webhooks pattern scales well for high-volume document generation:

php
<?php

namespace App\Jobs;

use App\Models\Report;
use App\Services\LatexPdfService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Storage;
use RuntimeException;

class GenerateReportPdf implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public int $tries = 2;
    public int $timeout = 120;

    public function __construct(private int $reportId) {}

    public function handle(LatexPdfService $latex): void
    {
        $report = Report::findOrFail($this->reportId);

        $source = view('pdfs.report', ['report' => $report])->render();

        try {
            $pdf = $latex->compile($source, 'latexmk');
        } catch (RuntimeException $e) {
            $report->update(['pdf_status' => 'failed', 'pdf_error' => $e->getMessage()]);
            $this->fail($e);
            return;
        }

        $path = "reports/{$report->id}/report.pdf";
        Storage::disk('s3')->put($path, $pdf);

        $report->update(['pdf_status' => 'ready', 'pdf_path' => $path]);
    }
}

Dispatch it from a controller:

php
GenerateReportPdf::dispatch($report->id);
return response()->json(['status' => 'queued']);

The controller returns immediately. The PDF appears in S3 once the job completes, and you can poll or use a webhook to notify the user.

Choosing the Right Plan

PlanCompilations/moEnginesBest for
Free15pdflatexPrototyping, testing the integration
Developer ($12/mo)500all 4Small apps, side projects
Pro ($49/mo)2,000all 4Production apps with moderate volume
Scale ($199/mo)10,000all 4High-volume document generation

The free tier is enough to build and test a complete integration. Upgrade when you hit the monthly limit or need xelatex/lualatex for Unicode and custom font support.

Getting Started

The integration path is straightforward:

  1. Sign up at formatex.io — no credit card required for the free tier.
  2. Create an API key in the dashboard under API Keys. Follow API key management best practices to store and rotate keys securely.
  3. Add FORMATEX_API_KEY to your .env.
  4. Drop the LatexPdfService into your Laravel app and wire it to a controller or job.
  5. Build your LaTeX template as a Blade view and render it with live data.

You get professional-quality PDF output without touching TeX Live, without managing a compilation server, and without any of the security and maintenance overhead that comes with shelling out from PHP. The API handles the infrastructure — you ship the feature.

Start compiling at formatex.io.

\end{article}

Back to blog

\related{posts}

One quick thing

We track anonymous usage — page views, feature usage, compilation events — to understand what works and what doesn't. No ads, no personal data, no third-party sharing.

Cookie policy