Fix rotated image uploads in PHP with jpegtran
To fix a sideways JPEG upload, transform the stored image according to its EXIF orientation, then reset that orientation. The PHP command below saves a separate, correctly oriented JPEG without another lossy compression pass. It refuses damaged input and rotations that would leave an uncorrected strip along an image edge.
Understand EXIF orientation data
An EXIF orientation tag tells a viewer how to display the stored image. Changing only that tag does
not rotate the image data; rotating the data without resetting the tag can make a viewer rotate it
again. Read the main image’s IFD0 orientation, rather than an embedded thumbnail’s orientation.
All eight values matter, including the mirrored cases. These are the operations to apply to the stored image, with rotation angles measured clockwise:
| EXIF value | Correction | jpegtran arguments |
|---|---|---|
| 1 | Leave upright | No transform |
| 2 | Mirror left to right | -flip horizontal |
| 3 | Turn upside down | -rotate 180 |
| 4 | Mirror top to bottom | -flip vertical |
| 5 | Reflect across the top-left to bottom-right diagonal | -transpose |
| 6 | Rotate 90° | -rotate 90 |
| 7 | Reflect across the top-right to bottom-left diagonal | -transverse |
| 8 | Rotate 270° | -rotate 270 |
The mapping follows ExifTool’s orientation definitions. When the main image has no orientation tag, this example leaves its geometry unchanged. It cannot infer which way an untagged photo should face. A value outside 1–8 is an error.
Use jpegtran for lossless rotation
jpegtran rearranges quantized DCT coefficients, avoiding the JPEG decode-and-recompress cycle of a
GD or ImageMagick rotation. Here, “lossless” describes that coefficient transform; the output file’s
bytes and metadata will change.
Perfect transforms depend on JPEG block boundaries. -perfect refuses an unsupported transform;
-trim would discard edge pixels. Use -strict as well to treat decoder warnings, including
truncated image data, as failures. These options are documented in the
libjpeg-turbo usage guide.
This example creates a display copy. -copy icc retains the color profile but discards other source
metadata, including GPS, camera details, copyright fields, XMP, comments, and embedded thumbnails.
ExifTool then writes a fresh EXIF Orientation value of 1. Keep the original if you need its metadata.
Using -copy all instead would require a separate policy for stale orientation fields and previews.
Prepare the Linux command line
Use PHP CLI with its EXIF extension, libjpeg-turbo’s jpegtran, and ExifTool installed and available
on PATH. This walkthrough was tested on Linux with PHP 8.5.10, libjpeg-turbo 3.2.0, and ExifTool
13.55. Use a private local directory on a filesystem that supports hard links for the output.
Check your existing installation before saving the script:
php -d extension=exif -r 'if (!extension_loaded("exif") || !function_exists("proc_open")) { fwrite(STDERR, "PHP needs EXIF and proc_open.\n"); exit(1); } echo "PHP ", PHP_VERSION, ": EXIF and proc_open available\n";' &&
jpegtran -version &&
exiftool -ver
The -d extension=exif option enables an installed shared EXIF extension for this invocation. If
EXIF is already enabled, omit that option throughout; loading it twice produces a warning. If PHP
cannot load it, install the matching extension for your PHP build before continuing. See
PHP’s EXIF installation instructions and the
CLI configuration options.
The version checks should identify libjpeg-turbo and ExifTool, with no PHP startup warnings.
Save the complete PHP command
Save this as orient-jpeg.php. It accepts an input path and a new output path. It uses
exif_read_data() with separate sections
and rejects metadata-read warnings. Even orientation 1 and untagged JPEGs pass through jpegtran;
a missing orientation tag is never a reason to copy unchecked bytes.
proc_open() with an argument array launches
the tools without a shell. Input paths are resolved to absolute paths, so a filename beginning with
a hyphen is not interpreted as a tool option. Choose paths in your application code when adapting
this command to a worker; the example does not authorize paths supplied by an HTTP client.
<?php
declare(strict_types=1);
function runTool(array $arguments): void
{
$process = proc_open($arguments, [
0 => ['file', '/dev/null', 'r'],
1 => ['file', '/dev/null', 'w'],
2 => STDERR,
], $pipes);
if ($process === false || proc_close($process) !== 0) {
throw new RuntimeException($arguments[0] . ' failed; see the diagnostic above.');
}
}
function orientJpeg(string $input, string $output): string
{
$source = realpath($input);
if ($source === false || !is_file($source) || !is_readable($source)) {
throw new RuntimeException('Input must be an existing, readable local file.');
}
$directory = realpath(dirname($output));
if ($directory === false || !is_dir($directory) || !is_writable($directory)) {
throw new RuntimeException('Output directory must exist and be writable.');
}
$destination = $directory . DIRECTORY_SEPARATOR . basename($output);
if (file_exists($destination) || is_link($destination)) {
throw new RuntimeException('Output already exists; choose a new filename.');
}
$metadata = exif_read_data($source, null, true);
if ($metadata === false) {
throw new RuntimeException('Cannot read JPEG headers.');
}
$orientation = $metadata['IFD0']['Orientation'] ?? 1;
$transforms = [
1 => [],
2 => ['-flip', 'horizontal'],
3 => ['-rotate', '180'],
4 => ['-flip', 'vertical'],
5 => ['-transpose'],
6 => ['-rotate', '90'],
7 => ['-transverse'],
8 => ['-rotate', '270'],
];
if (!is_int($orientation) || !isset($transforms[$orientation])) {
throw new RuntimeException('EXIF Orientation must be an integer from 1 to 8.');
}
$temporary = tempnam($directory, '.orient-');
if ($temporary === false) {
throw new RuntimeException('Cannot create a temporary output.');
}
try {
runTool([
'jpegtran', '-strict', '-perfect', '-copy', 'icc',
...$transforms[$orientation], '-outfile', $temporary, $source,
]);
runTool(['exiftool', '-overwrite_original', '-IFD0:Orientation#=1', $temporary]);
$written = exif_read_data($temporary, null, true);
if (($written['IFD0']['Orientation'] ?? null) !== 1) {
throw new RuntimeException('Could not verify the output orientation tag.');
}
// A hard link publishes the finished file without replacing an existing name.
if (!link($temporary, $destination)) {
throw new RuntimeException('Could not create the output; choose a new filename.');
}
} finally {
unlink($temporary);
}
return $destination;
}
// Reject PHP warnings, including unreadable or malformed metadata, instead of guessing.
set_error_handler(static function (int $severity, string $message): never {
throw new ErrorException($message, 0, $severity);
});
try {
if ($argc !== 3) {
throw new RuntimeException('Usage: php orient-jpeg.php INPUT.jpg OUTPUT.jpg');
}
if (!extension_loaded('exif') || !function_exists('proc_open')) {
throw new RuntimeException('PHP needs EXIF and proc_open enabled.');
}
$output = orientJpeg($argv[1], $argv[2]);
fwrite(STDOUT, "Saved: $output\n");
} catch (Throwable $error) {
fwrite(STDERR, 'Failed: ' . $error->getMessage() . "\n");
exit(1);
}
Only the temporary file is passed to ExifTool’s
-overwrite_original
option. The source stays untouched. PHP’s link()
creates the destination after processing succeeds; an existing file or symlink is refused. A
finally block removes the temporary name on both success and ordinary errors.
Run it on an uploaded JPEG
Place an actual JPEG named photo.jpg beside the script, then run:
php -d extension=exif orient-jpeg.php photo.jpg photo-fixed.jpg
A successful run prints Saved: followed by the absolute output path and exits with status 0.
Failures write a diagnostic to standard error and exit with status 1. Running the same command again
refuses to overwrite photo-fixed.jpg. Quote paths containing spaces.
Inspect the resulting metadata:
exiftool -n -IFD0:Orientation -ImageWidth -ImageHeight ./photo-fixed.jpg
Expect Orientation to be 1. Orientations 5–8 exchange the width and height. Open the output and check an asymmetric feature, such as lettering, to confirm both its rotation and handedness. Reprocessing that output to a different filename should leave the image’s geometry unchanged.
Handle refused files and batch jobs
A “transformation is not perfect” diagnostic means the requested operation cannot transform every
edge block. A 90° rotation of a JPEG with a partial bottom block is one example. Keep the original
and choose whether your application permits cropping or a decoded, re-encoded derivative. Removing
-perfect silently changes that decision and can leave an edge strip incorrectly oriented.
Empty files, non-JPEG files, truncated image data, and invalid orientation values fail instead of producing a success message. Progressive JPEG input is accepted; this command does not request progressive output. It also does not promise conversion to a particular JPEG compatibility profile.
For a queue or batch, invoke the command once per input with a distinct destination and record each exit status. Retry refused files only after choosing how to handle their failure. Keep these jobs outside the HTTP request: enforce upload size and resource limits, keep tool diagnostics in worker logs, and return a simple failure message to the client. The local command covers orientation normalization, not upload authentication or worker timeouts.
