Merging PDF documents in PHP using FPDI and FPDF
To merge PDFs in PHP, import every source page with FPDI and create a matching output page with FPDF. This command-line example combines local PDFs in the order you supply them, preserves mixed page sizes, and fails if any requested input cannot be imported. You will generate two sample PDFs, merge them, and check the resulting three pages.
Prerequisites
Use PHP 8.3–8.5, Composer 2, and a writable local directory. This walkthrough was tested in a Bash shell on Linux with PHP 8.5.10, Composer 2.10.3, FPDI 2.6.8, and FPDF 1.9.0.
Enable the GD and Zlib extensions in the PHP CLI before installing dependencies. Both are
declared by FPDF’s Composer manifest;
FPDI also requires Zlib. Check the CLI
configuration with php --ini and its loaded extensions with php -m. A web server can use a
different PHP configuration.
Install Poppler’s pdfinfo and pdftotext commands if you want to run the independent checks below.
They inspect the result; they are not dependencies of the PHP merge script.
Install FPDI and FPDF
Start in a parent directory where you want to create a new pdf-merge-demo project:
mkdir pdf-merge-demo &&
cd pdf-merge-demo &&
composer require --no-interaction 'setasign/fpdf:1.9.0' 'setasign/fpdi:2.6.8'
The && chain stops if directory creation or navigation fails. If the project already exists,
choose another name; do not delete it to rerun setup. Continue only after Composer succeeds, keeping
the generated composer.lock for reproducible installations. Save the following scripts inside
pdf-merge-demo and run all remaining commands from that directory.
Generate sample PDFs
Save this as samples.php. It creates a.pdf with an A4 portrait page and an A3 landscape page,
plus b.pdf with a US Letter portrait page. The labels make page order easy to check. Running this
sample generator again replaces a.pdf and b.pdf in the tutorial directory.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
$samples = [
'a.pdf' => [['P', 'A4', 'A1'], ['L', 'A3', 'A2']],
'b.pdf' => [['P', 'Letter', 'B1']],
];
foreach ($samples as $name => $pages) {
$pdf = new FPDF();
foreach ($pages as [$orientation, $format, $label]) {
$pdf->AddPage($orientation, $format);
$pdf->SetFont('Helvetica', '', 20);
$pdf->Cell(0, 10, $label);
}
$pdf->Output('F', __DIR__ . '/' . $name);
}
php samples.php
Import existing PDFs with FPDI
FPDI creates a new document from imported page content. Its
setSourceFile() method
returns the source page count. For each page, the merge loop below calls importPage(), obtains its
dimensions with getTemplateSize(), and passes those dimensions and orientation to AddPage().
Calling AddPage() without those arguments would use the default A4 portrait page instead.
By default, importPage() uses the CropBox, the visible page boundary, falling back to the
MediaBox when necessary. The output matches that imported area, including the source page’s
rotation; it does not preserve separate print-production boxes. See the
FPDI page-boundary implementation.
Merge any number of PDFs
Save this complete script as merge.php. mergeMany() returns the number of output pages on
success and throws on failure. It never skips an input. The CLI catches failures, writes an error to
standard error, and exits with status 1.
The output policy differs from the disposable sample generator: the merge refuses to overwrite
an existing destination, including an input file. It finishes importing and serializing all pages
before opening the output. PHP’s xb file mode
creates a new binary file exclusively, so a rerun cannot truncate a previous result.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use setasign\Fpdi\Fpdi;
function mergeMany(array $files, string $out): int
{
if ($files === []) {
throw new InvalidArgumentException('Provide at least one input PDF.');
}
$pdf = new Fpdi();
foreach ($files as $file) {
if (!is_file($file) || !is_readable($file)) {
throw new RuntimeException("Cannot read input PDF: $file");
}
$pageCount = $pdf->setSourceFile($file);
if ($pageCount < 1) {
throw new RuntimeException("Input PDF has no pages: $file");
}
for ($page = 1; $page <= $pageCount; $page++) {
$template = $pdf->importPage($page);
$size = $pdf->getTemplateSize($template);
$pdf->AddPage($size['orientation'], [$size['width'], $size['height']]);
$pdf->useTemplate($template);
}
}
// Serialization can fail too; do it before creating the destination.
$bytes = $pdf->Output('S');
$handle = @fopen($out, 'xb');
if ($handle === false) {
throw new RuntimeException("Cannot create output: $out (exists or is not writable).");
}
try {
if (fwrite($handle, $bytes) !== strlen($bytes) || !fflush($handle)) {
throw new RuntimeException("Could not write the complete PDF: $out");
}
} catch (Throwable $error) {
fclose($handle);
unlink($out);
throw $error;
}
fclose($handle);
return $pdf->PageNo();
}
try {
if ($argc < 2) {
throw new InvalidArgumentException('Usage: php merge.php OUTPUT.pdf INPUT.pdf ...');
}
$pages = mergeMany(array_slice($argv, 2), $argv[1]);
fwrite(STDOUT, "Merged $pages pages into {$argv[1]}\n");
} catch (Throwable $error) {
fwrite(STDERR, 'Merge failed: ' . $error->getMessage() . "\n");
exit(1);
}
Output('S') returns PDF bytes as a string. Import or
serialization failures leave the destination untouched. A detected write failure removes the new,
incomplete output. This local script is not a crash-safe publication mechanism: interruption during
writing can leave a partial file, so consumers should wait for a successful exit.
Merge two PDFs
Run the script with the destination first, followed by the inputs in the desired order:
php merge.php merged.pdf a.pdf b.pdf
It should print Merged 3 pages into merged.pdf and exit with status 0. More input paths use the
same command; quote paths containing spaces. To reorder these samples, write a separate result:
php merge.php reversed.pdf b.pdf a.pdf
The labels in reversed.pdf should be B1, A1, and A2. Source files remain unchanged.
Check the result and failure behavior
pdfinfo -f 1 -l 3 merged.pdf &&
pdftotext -layout merged.pdf -
Expect Pages: 3, these page dimensions, and the labels A1, A2, and B1 in order. PDF points
are 1/72 inch; small rounding differences are normal.
| Page | Label | Format | Width × height in points |
|---|---|---|---|
| 1 | A1 | A4 portrait | 595.28 × 841.89 |
| 2 | A2 | A3 landscape | 1190.55 × 841.89 |
| 3 | B1 | US Letter portrait | 612 × 792 |
A missing input must fail the whole request, even when valid inputs surround it. Leave missing.pdf
absent and use a new destination:
php merge.php incomplete.pdf a.pdf missing.pdf b.pdf
This exits with status 1, reports Cannot read input PDF: missing.pdf, and creates no
incomplete.pdf. An unreadable file, a directory used as input, or a malformed PDF also fails.
Supplying only an output path fails because the input list is empty. Repeating the successful merge
command fails because merged.pdf exists; its bytes stay unchanged. Choose a new output name to
keep both versions.
Know what the free parser can preserve
These are static page imports. This example does not copy interactive form fields, annotations,
clickable links, bookmarks, layers, or document actions. FPDI can optionally import external URI
link annotations with importPage()’s importExternalLinks parameter, but it defaults to false
here. That option does not restore forms, internal navigation, or other annotations.
The free parser also rejects encrypted/password-protected PDFs and PDFs using compressed cross-reference streams or object streams. Ordinary compressed page content is supported, as in the FPDF samples. A PDF version number alone does not tell you whether the unsupported structures are present. These restrictions are documented in FPDI’s limitations.
If you see the unsupported-compression error, obtain a compatible export or evaluate Setasign’s optional FPDI PDF-Parser add-on. Extending the parser does not turn a page-content import into a merge that retains the entire document’s interactive features. Choose a document-level merger when those features are required.
Manage memory for large files
FPDF builds the output in memory, and this script also retains the string returned by Output('S')
while saving it. Processing one page at a time therefore does not make the job a streaming merge.
Measure peak memory with representative inputs before choosing a PHP memory limit or job size;
there is no dependable limit based on the number of PDFs alone.
Releasing the FPDI object after a job can free its resources, but garbage collection after the merge cannot reduce that job’s peak memory. For a long-running worker, release each completed job’s objects before starting the next one.
