Efficient JPEG optimization in PHP with jpegoptim
Run jpegoptim on a temporary copy, check its exit status, and publish the result only when the chosen output name is still available. This PHP CLI example leaves your source JPEG untouched and refuses to replace an existing output, including when the input is corrupt.
Choose what to change
Lossless JPEG optimization rearranges compressed data without another quality-reducing encode. It does not mean the resulting file has identical bytes. The script below offers three separate modes:
| Mode | jpegoptim options | Intended change |
|---|---|---|
lossless (default) | --strip-none | Optimize compression while retaining metadata. |
strip | --strip-all --force | Remove metadata without reducing JPEG quality. |
quality80 | --strip-none --max=80 | Allow quality reduction while retaining metadata. |
The jpegoptim manual describes
--max as a quality ceiling, not a percentage reduction in file size. A JPEG already below that
quality can take the lossless path. Avoid --max and --size when preserving image quality is
the requirement.
Metadata needs a separate decision: stripping EXIF can lose orientation, and stripping ICC profiles
can change color rendering. Use strip only when those changes are acceptable. It forces a rewrite
so metadata removal is not skipped for lack of size savings. Even --strip-all can leave
encoder-generated JFIF or Adobe markers; --strip-none also permits those markers to be regenerated.
Neither mode promises a byte-for-byte metadata archive.
Install the CLI tools
Use a local Linux directory that you control, with room for a temporary copy and a filesystem that
supports hard links. You need PHP CLI with proc_open() enabled and the jpegoptim executable.
On Ubuntu 24.04, install both through APT:
sudo apt-get update &&
sudo apt-get install -y php-cli jpegoptim &&
php --version &&
jpegoptim --version
Ubuntu 24.04 packages jpegoptim 1.4.7, which supports the options used here. This workflow was tested with PHP 8.3 and jpegoptim 1.4.7, and with PHP 8.5.10 and jpegoptim 1.5.6. It does not require Composer, GD, or a web server.
Create a new working directory:
mkdir jpeg-demo && cd jpeg-demo
If that command fails, stop and choose an unused directory name. Put a JPEG you own in this directory
as input.jpg. Save the following script beside it as optimize-jpeg.php.
Write a separate output from PHP
The output directory must already exist. The script checks for collisions before doing work, then
uses PHP’s link() to give the completed
temporary file its final name. That operation also refuses an output created while jpegoptim was
running. Cleanup removes only the temporary name.
<?php
declare(strict_types=1);
function main(array $args): void
{
if (count($args) < 3 || count($args) > 4) {
throw new InvalidArgumentException(
'Usage: php optimize-jpeg.php INPUT OUTPUT [lossless|strip|quality80]'
);
}
$options = match ($args[3] ?? 'lossless') {
'lossless' => ['--strip-none'],
'strip' => ['--strip-all', '--force'],
'quality80' => ['--strip-none', '--max=80'],
default => throw new InvalidArgumentException('Unknown optimization mode.'),
};
$binary = getenv('JPEGOPTIM_BINARY') ?: '/usr/bin/jpegoptim';
if (!str_starts_with($binary, '/') || !is_file($binary) || !is_executable($binary)) {
throw new RuntimeException('Set JPEGOPTIM_BINARY to an executable absolute path.');
}
$input = realpath($args[1]);
if ($input === false || !is_file($input) || !is_readable($input)) {
throw new RuntimeException('Input must be a readable local file.');
}
$directory = realpath(dirname($args[2]));
if ($directory === false || !is_dir($directory) || !is_writable($directory)) {
throw new RuntimeException('Output directory must exist and be writable.');
}
$output = $directory . '/' . basename($args[2]);
if (file_exists($output) || is_link($output)) {
throw new RuntimeException('Output already exists; choose a new filename.');
}
$before = filesize($input);
if ($before === false || $before === 0) {
throw new RuntimeException('Input is empty or its size cannot be read.');
}
$temporary = tempnam($directory, '.jpegoptim-');
if ($temporary === false) {
throw new RuntimeException('Cannot create a temporary file.');
}
try {
// tempnam can fall back to the system temp directory; keep publication on one filesystem.
if (dirname($temporary) !== $directory || !copy($input, $temporary)) {
throw new RuntimeException('Cannot create the working copy in the output directory.');
}
$process = proc_open(
[$binary, '--nofix', '--quiet', ...$options, '--', $temporary],
[0 => ['file', '/dev/null', 'r'], 1 => STDERR, 2 => STDERR],
$pipes
);
if (!is_resource($process)) {
throw new RuntimeException('Cannot start jpegoptim.');
}
$status = proc_close($process);
if ($status !== 0) {
throw new RuntimeException("jpegoptim failed (exit $status); no output published.");
}
clearstatcache(true, $temporary);
$after = filesize($temporary);
if ($after === false || $after === 0) {
throw new RuntimeException('jpegoptim did not leave a nonempty output.');
}
if (!link($temporary, $output)) {
throw new RuntimeException('Cannot publish output; check for a collision or filesystem error.');
}
} finally {
unlink($temporary);
}
printf("Created %s: %d -> %d bytes; saved %d bytes\n", $output, $before, $after, $before - $after);
}
try {
main($argv);
} catch (Throwable $error) {
fwrite(STDERR, 'Error: ' . $error->getMessage() . "\n");
exit(1);
}
proc_open() accepts an argument array,
so paths do not become shell commands. jpegoptim receives only the temporary file, never the source
or final destination. The --nofix option rejects decompression warnings as well as errors,
instead of attempting to repair a damaged JPEG. A MIME check alone would not establish that the
whole image can be decoded.
Run the default lossless mode:
php optimize-jpeg.php input.jpg lossless.jpg
The script uses /usr/bin/jpegoptim, the Ubuntu package’s location. If you installed it elsewhere,
set JPEGOPTIM_BINARY to its absolute path for the invocation. Do not accept that setting or
arbitrary optimizer flags from an HTTP request.
A successful run exits with status zero and prints the output path, input and output byte counts,
and bytes saved. Zero savings is valid: you still get a separate output. A negative saving means
the output grew, which the forced strip mode permits. Running the command again with the same
output name fails without changing that file. Using the input name as the output fails too.
A corrupt input, a missing executable, or a failed jpegoptim process produces a nonzero exit status. No final output is published on those failures, and the temporary copy is removed during normal exception handling. Existing outputs and the source stay in place.
Measuring compression results
Compare the other modes using new output names:
php optimize-jpeg.php input.jpg stripped.jpg strip &&
php optimize-jpeg.php input.jpg quality80.jpg quality80
Record the byte counts alongside your installed versions and mode. Compare representative photos, small thumbnails, and images already optimized by another tool; a saving on one does not predict a saving on another. The script does not resize images or guarantee a target file size.
Open the outputs in an image viewer. For quality80, inspect fine detail, gradients, and text at
full size before accepting the quality change. For strip, check orientation and color against the
original. If you automate a lossless check, decode the source and output with the same decoder and
compare pixel buffers and dimensions; comparing JPEG file hashes tests byte identity instead.
Call the script from a worker
For a batch job or a Laravel application, keep the original and allocate a fresh destination for each image. Run this CLI operation in a worker and check its exit status before recording an output as available. A failed job should retain the source for diagnosis or retry. A Composer wrapper still needs the native executable and an explicit policy for failures and destination collisions.
Security considerations
This example processes local files in directories controlled by the same trusted account. It is
not an upload endpoint or a sandbox for hostile files. It leaves output permissions private
(the temporary file starts with mode 0600);
arrange any later web-serving permissions deliberately.
An upload service also needs byte and pixel limits, isolated processing, and a worker deadline that
terminates the external process. PHP’s set_time_limit()
does not bound time spent in external operations on Linux. This small CLI script has no process
timeout, and a forced kill can leave temporary files behind. Keep its working directory private and
handle interrupted-job cleanup in the worker that owns those files.
