Stream Tar archives in PHP without memory limits
Creating large tar archives in PHP can be tricky once your data set grows beyond a few hundred
megabytes. While PHP's PharData class is stream-oriented for many operations, the overall process
still operates within PHP’s configured memory_limit. When archiving a vast number of files or very
large individual files, you might encounter the "Allowed memory size exhausted" error, especially
with default limits like 128M. Fortunately, we can call the system-level tar utility with
proc_open() and let the operating system handle the heavy lifting. The result is a constant-memory
stream that you can pipe straight to the browser, object storage, or another process, enabling
efficient PHP tar streaming.
Understand PHP memory limits with PharData
While PharData is efficient for many operations, it may face memory constraints with very large
archives or when performing bulk operations. The main limitation comes from PHP's memory_limit
setting rather than PharData itself. For instance, buffering, metadata handling, and opcode memory
can collectively contribute to exceeding this limit when dealing with thousands of files or
exceptionally large ones.
If you only need a write-once, read-never archive, there is often no significant benefit in keeping
the entire operation within the PHP process. Offloading the work to the system's tar command frees
you from PHP’s memory limitations and provides streaming capabilities inherently. This makes it an
excellent solution for memory efficient tar creation.
Stream Tar output with proc_open()
proc_open() starts an external command and exposes its standard input, output, and error streams
as PHP resources. The following helper function reads from the tar command's stdout and pushes
the bytes directly to the client, ensuring memory usage remains constant, which is ideal for a PHP
tar download.
<?php
/**
* Report whether an already-resolved path sits inside an already-resolved root.
* Both arguments must come from realpath() so that symlinks and '..' are gone.
* Passing '/' as a root allows the entire filesystem, so only configure it deliberately.
*/
function isWithin(string $path, string $root): bool
{
return $path === $root || str_starts_with($path, rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
}
/**
* Reduce a download name to a conservative ASCII attachment filename.
* Everything outside [A-Za-z0-9._-] is replaced, so no quote, backslash, or control
* character can escape the quoted-string in Content-Disposition. A backslash matters as
* much as a quote here: "report\" ends the value one character later than it looks.
*/
function safeAttachmentName(string $downloadName, string $fallback = 'archive.tar'): string
{
$name = trim((string) preg_replace('/[^A-Za-z0-9._-]+/', '_', basename($downloadName)), '._');
return $name === '' ? $fallback : substr($name, 0, 100);
}
function clearOutputBuffers(): void
{
// Any remaining framework buffer would accumulate the archive despite flush().
while (ob_get_level() > 0) {
$status = ob_get_status();
if (($status['flags'] & PHP_OUTPUT_HANDLER_REMOVABLE) === 0 || !ob_end_clean()) {
throw new RuntimeException('Cannot disable output buffering for this response.');
}
}
}
function streamTarArchive(string $directory, string $downloadName = 'archive.tar'): void
{
$safeDownloadName = safeAttachmentName($downloadName);
// tar reads a leading '-' as an option, so a directory literally named
// "--checkpoint-action=exec=..." would run a command. realpath() gives an absolute
// path, and '--' ends option parsing for anything it still cannot cover.
$realDir = realpath($directory);
if ($realDir === false || !is_dir($realDir)) {
throw new InvalidArgumentException('Directory does not exist or is not accessible.');
}
clearOutputBuffers();
$cmd = 'tar -C ' . escapeshellarg($realDir) . ' -cf - -- .';
// stdin is never used by `tar -c`, and stderr is discarded. Capturing stderr would
// mean either draining a second stream while stdout is mid-transfer, or buffering it,
// and a chatty tar can emit one line per file. The exit code is the diagnostic we keep.
$descriptorSpec = [
0 => ['file', '/dev/null', 'r'],
1 => ['pipe', 'w'],
2 => ['file', '/dev/null', 'w'],
];
$pipes = [];
$process = proc_open($cmd, $descriptorSpec, $pipes);
if (!is_resource($process)) {
throw new RuntimeException('Failed to create tar process. Is tar installed and in PATH?');
}
header('Content-Type: application/x-tar');
header('Content-Disposition: attachment; filename="' . $safeDownloadName . '"');
header('X-Content-Type-Options: nosniff'); // Security: prevent MIME-sniffing
header('Cache-Control: private, no-store'); // Build logs are confidential: do not cache
// Stream the tar output. fread() returns '' at EOF and false on a real read error,
// and those two have to stay distinguishable: treating false as EOF reports a
// truncated archive as a complete one.
$readFailed = false;
while (true) {
$chunk = fread($pipes[1], 8192); // Read in 8KB chunks
if ($chunk === false) {
$readFailed = true;
break;
}
if ($chunk === '') {
break; // EOF
}
echo $chunk;
flush(); // Flush output to the client
}
fclose($pipes[1]);
// Use one completion path: wait for tar and collect its exit code here.
$exitCode = proc_close($process);
if ($readFailed) {
throw new RuntimeException('Failed to read the tar output stream.');
}
if ($exitCode !== 0) {
throw new RuntimeException("tar exited with status {$exitCode}");
}
}
This function never stages the archive: no temporary file, and no growing buffer in PHP. Bytes move
from tar's stdout to the client in 8 KB chunks, so PHP memory usage stays flat regardless of
archive size. The helper clears every nested PHP output buffer before starting; if a framework
prevents that, it fails explicitly instead of buffering the download. Web-server and proxy buffering
need separate configuration. tar still reads source files from disk, and archive member paths are
relative to the selected directory rather than including its full filesystem path.
Two assumptions are baked in. str_starts_with() needs PHP 8.0+, and /dev/null plus a POSIX
tar that understands -cf - and -- means Unix; both GNU tar and bsdtar qualify, Windows does
not.
There is also one limitation worth being explicit about: the archive is valid only when tar exits
with status 0, and that is known only after the last byte has been sent. Once bytes are flushed the
response status is already on the wire and cannot be retracted, so the failure is raised to the
caller and logged server-side while the client sees a truncated archive it has to detect itself. If
you need the failure to be unambiguous, stage the archive to a temporary file first and only then
serve it, trading constant memory for disk space.
Archive CI/CD logs in real time
Continuous integration (CI/CD) servers often generate numerous small log files, good candidates
for real-time archiving PHP streaming. Build logs are confidential, so everything below assumes it
runs after your framework's existing authentication and per-project authorization: the route must
already have established who the caller is and that they may read this project's logs. There is no
public log download here, and this DevTip does not try to show you an authorization system. The
snippet's own job is narrower: map an opaque identifier to a path, confirm that path is inside the
allowed root, and stream it. Save the first example as tar-streaming.php and load it with
require_once before using any of the following examples; they share its helper functions.
<?php
// Mount this behind your existing auth middleware. It assumes the caller is already
// authenticated and already authorized for this project's build logs.
function downloadCiLogs(string $logDirIdentifier): void
{
// Example: map an identifier to an actual path
// In a real app, this might come from a config or database, scoped to the caller's project
$logPathMappings = [
'project-alpha-build-123' => '/var/logs/ci-cd/project-alpha/build-123',
'project-beta-deploy-45' => '/var/logs/ci-cd/project-beta/deploy-45',
];
if (!isset($logPathMappings[$logDirIdentifier])) {
throw new InvalidArgumentException('Invalid log directory identifier.');
}
// Use realpath to resolve symbolic links and '..'
$realDir = realpath($logPathMappings[$logDirIdentifier]);
$allowedRoot = realpath('/var/logs/ci-cd'); // Ensure this base path is secure
if ($realDir === false || !is_dir($realDir)) {
throw new InvalidArgumentException('Directory does not exist or is not accessible.');
}
// Compare against the root plus a separator. A bare prefix check would also accept
// a sibling such as /var/logs/ci-cd-evil for the root /var/logs/ci-cd.
if ($allowedRoot === false || !isWithin($realDir, $allowedRoot)) {
throw new RuntimeException('Access denied to the specified directory.');
}
streamTarArchive($realDir, 'ci-logs-' . $logDirIdentifier . '-' . date('Y-m-d') . '.tar');
}
// Example route handler, running after auth and authorization have already passed:
try {
$logIdentifier = $_GET['log_id'] ?? '';
if (!is_string($logIdentifier) || !preg_match('/^[a-zA-Z0-9_-]{1,64}$/', $logIdentifier)) {
throw new InvalidArgumentException('Invalid log identifier format.');
}
downloadCiLogs($logIdentifier);
} catch (Throwable $e) {
// Diagnostics stay server-side. Echoing $e->getMessage() would hand the client
// filesystem paths and tar internals, and would confirm which identifiers exist.
error_log('CI log download failed: ' . $e->getMessage());
// headers_sent() is the whole story here: once streamTarArchive() has flushed a byte,
// the 200 and its headers are already gone and nothing below can undo them.
if (!headers_sent()) {
header_remove();
http_response_code($e instanceof InvalidArgumentException ? 400 : 500);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: private, no-store');
echo "The archive could not be generated.\n";
}
}
Report progress with server-sent events
When archiving thousands of files, progress feedback improves the experience. Since tar -v prints
each filename to stderr as it is processed, those lines can be streamed as Server-Sent Events
(SSE).
Be clear about what this is: a second, independent tar run whose archive output is thrown
away. It reports progress for an equivalent archive, not for the download in the previous section.
Those are two separate processes with no shared state, and correlating them would need a job ID plus
a progress store that both requests can reach, which this example does not implement. Use it as a
pre-flight estimate, not as a progress bar for an in-flight download.
<?php
function sseTarProgress(string $directory, array $allowedRoots): void
{
// Same authentication and authorization assumptions as downloadCiLogs() above.
$realDir = realpath($directory);
if ($realDir === false || !is_dir($realDir)) {
throw new InvalidArgumentException('Directory does not exist or is not accessible.');
}
$isAllowed = false;
foreach ($allowedRoots as $root) {
$realRoot = realpath($root);
if ($realRoot !== false && isWithin($realDir, $realRoot)) {
$isAllowed = true;
break;
}
}
if (!$isAllowed) {
throw new RuntimeException('Access denied to the specified directory for progress reporting.');
}
clearOutputBuffers();
header('Content-Type: text/event-stream');
header('Cache-Control: private, no-store');
header('Connection: keep-alive'); // Important for SSE
// -v sends one filename per file to stderr. The archive itself goes to /dev/null:
// this run exists only to report progress, so stdout can never fill and block.
$descriptorSpec = [
0 => ['file', '/dev/null', 'r'], // stdin - not used by tar -c
1 => ['file', '/dev/null', 'w'], // stdout - the archive, discarded
2 => ['pipe', 'w'], // stderr - where tar -v outputs filenames
];
$pipes = [];
$process = proc_open('tar -C ' . escapeshellarg($realDir) . ' -cvf - -- .', $descriptorSpec, $pipes);
if (!is_resource($process)) {
throw new RuntimeException('tar process failed to start for progress reporting.');
}
ignore_user_abort(true);
set_time_limit(0); // Allow script to run indefinitely for long tar processes
// A plain blocking read to EOF. Note what this does not do: there are no periodic
// keep-alives, so a tar that stays silent for minutes sends nothing at all. If a proxy
// between you and the client times idle connections out, drive this with
// stream_select() on a timeout and emit a ": keepalive" comment when it expires.
while (($line = fgets($pipes[2])) !== false) {
$line = trim($line);
if ($line === '') {
continue;
}
echo 'data: ' . json_encode(['file' => $line]) . "\n\n";
flush(); // Flush data to the client
}
fclose($pipes[2]);
// Exactly one terminal event, chosen by tar's real exit status. Emitting "complete"
// from a finally block would tell the client the archive is fine even when tar died.
$exitCode = proc_close($process);
if ($exitCode === 0) {
echo "event: complete\n" . 'data: ' . json_encode(['done' => true]) . "\n\n";
} else {
error_log('sseTarProgress: tar exited with status ' . $exitCode);
echo "event: error\n" . 'data: ' . json_encode(['message' => 'Archiving failed.']) . "\n\n";
}
flush();
}
The client consumes this stream via the JavaScript EventSource API. Treat each file value as an
opaque progress line: stderr also contains diagnostics, and GNU tar and bsdtar format entries
differently. It should treat error and complete as mutually
exclusive: exactly one of them arrives, and only complete means the archive would have been valid.
Harden proc_open() calls
Calling shell utilities from PHP always invites command-injection bugs if not handled carefully. Keep these rules in mind:
- Validate and Sanitize Inputs: Always validate any user-provided data. For file paths, resolve
them using
realpath()to get the canonicalized absolute pathname and to check existence. - Allowlist Directories: Maintain a strict allowlist of directories from which operations are permitted. Reject any paths not conforming to this list.
- Escape Shell Arguments: Crucially, escape every argument passed to shell commands using
escapeshellarg()orescapeshellcmd()as appropriate.escapeshellarg()is generally preferred for individual arguments. Never concatenate raw input directly into a shell command string. - Principle of Least Privilege: Run the PHP (and web server) process with the minimum necessary
privileges. Avoid running as
root. The user should only have read access to the directories being archived and execute permission for thetarutility. - Check exit codes, and pick one stderr strategy: Use
proc_close()to wait for completion and collect the exit code, as shown here. If you addproc_get_status()polling, account for the PHP version: before PHP 8.3 only its first call after exit returned the real code; later calls returned-1. PHP 8.3 added exit-code caching. Forstderr, either discard it (as the download helper does) or drain it concurrently under a strict size cap. Reading it only after the transfer means a chatty command fills the 64 KiB pipe buffer and blocks forever, and buffering it in full gives back the unbounded memory you switched to streaming to avoid. - Error Handling: Implement robust error handling around
proc_open()calls to manage scenarios like the command not being found, failing to start, or exiting with an error.
The SecureTarStreamer class example demonstrates a good approach by encapsulating path validation
and allowlist checks:
<?php
class SecureTarStreamer
{
private array $allowedRoots;
public function __construct(array $allowedRoots)
{
// Ensure allowedRoots are absolute and valid paths during construction
$this->allowedRoots = array_map(function($root) {
$realRoot = realpath($root);
if ($realRoot === false || !is_dir($realRoot)) {
throw new InvalidArgumentException("Invalid allowed root directory: {$root}");
}
return $realRoot;
}, $allowedRoots);
}
public function send(string $userSuppliedDir, string $downloadName = 'archive.tar'): void
{
$path = realpath($userSuppliedDir); // Resolve the user-supplied path
if ($path === false || !is_dir($path)) {
throw new InvalidArgumentException('Invalid or non-existent directory specified.');
}
$isAllowed = false;
foreach ($this->allowedRoots as $root) {
if (isWithin($path, $root)) {
$isAllowed = true;
break;
}
}
if (!$isAllowed) {
throw new RuntimeException('Access to the specified directory is not allowed.');
}
// Now it's safer to call the streaming function
streamTarArchive($path, $downloadName);
}
}
// Example Usage, again behind your existing authentication and authorization:
// $streamer = new SecureTarStreamer(['/var/www/safe_uploads', '/mnt/user_data']);
// $streamer->send($_GET['directory_to_archive']);
//
// The allowlist is checked with realpath(), so it inspects the tree and then hands the
// resolved path to tar. Between those two steps a symlink could be swapped. Keep the
// archived directories under your application's control rather than in a tree that other
// local users can write to.
Compare approaches
Performance characteristics vary by use case:
PharData: Optimal for archive manipulation (reading/writing individual files within an archive) when memory limits are not a concern or for smaller archives.proc_open+tarstreaming: Best for creating large archives with minimal PHP memory usage (PHP archive streaming), especially for write-once scenarios like backups or log archiving. This is a highly memory efficient tar method.- Transloadit Robot: Ideal for scalable, production use where you want to offload the entire process. It offers automatic optimization, handles various formats (tar, zip, etc.), and integrates with cloud storage.
Choose the method that best matches your workload: random file access, on-premises streaming, or fully managed cloud compression.
What about interrupted downloads?
The tar format is sequential, so true byte-range resumes are not natively supported in a simple HTTP download without third-party tools or server-side logic that can index the stream. In practice, clients might not always gracefully handle interruptions of multi-gigabyte downloads. If resuming is critical for your application:
- Split Data: Consider splitting the data into multiple smaller tar files. Clients can then download these individually, and a failure only affects one part.
- Resumable Transport Protocols: Use a resumable transport mechanism. After the tar archive is
produced (even if streamed to a temporary local file first, or directly piped), you could then
serve it or upload it using protocols like
tus.ioor leverage features like S3 multipart uploads if the destination is cloud storage. - Let Transloadit Handle It: Transloadit's platform is designed for robust file processing and delivery, inherently managing many complexities of large file transfers.
Wrap-up
Using proc_open() in combination with the system's tar utility is a simple yet powerful pattern
for creating large archives in PHP with a near-zero memory footprint within the PHP process itself.
This technique is particularly effective for tasks like archiving PHP CI/CD logs, creating nightly
backups, or handling any large dataset that needs to be written once and streamed efficiently. It's
a great way to achieve tar without memory limits in PHP.
Need something even more hands-off? Transloadit’s 🤖 /file/compress
Robot can create .tar (optionally gzipped) archives for you. A minimal
Assembly looks like this:
{
"steps": {
"compressed": {
"robot": "/file/compress",
"use": ":original",
"format": "tar",
"gzip": true
}
}
}
The Robot supports both tar and zip formats, with optional gzip compression. Give it
a try and let our infrastructure worry about memory limits, concurrency, and edge-case handling
while you focus on building features.
