Fix rotated image uploads in PHP with Jpegtran
Mobile devices often embed orientation data in photos, causing images to appear rotated when
uploaded to web applications. This common issue can frustrate users and developers alike. Luckily,
jpegtran—a tiny command-line program shipped with libjpeg—can rotate JPEGs without re-encoding, so
you fix orientation while keeping every pixel intact.
Understand exif orientation data
Photos taken with modern phones include EXIF metadata that records, among many other things, how the camera was held. Viewers that honor this field will render the picture upright, while software that ignores it will show the raw pixels—which is why freshly uploaded images sometimes look sideways or upside-down.
The orientation tag can hold these relevant values:
- 1 – Normal
- 3 – 180-degree rotation
- 6 – 90-degree clockwise rotation
- 8 – 90-degree counter-clockwise rotation
Rotate images in PHP with gd or ImageMagick
The classic PHP fix is to load the JPEG into GD or ImageMagick, rotate it, and write it back:
<?php
$image = imagecreatefromjpeg('photo.jpg');
$rotated = imagerotate($image, 90, 0);
imagejpeg($rotated, 'photo-fixed.jpg');
It works, but the file is decompressed and recompressed, which can soften fine details and burn CPU cycles on busy servers.
Use jpegtran for lossless rotation
jpegtran transforms quantized DCT coefficients without another lossy compression pass. Perfect
rotations and flips depend on the image's MCU (Minimum Coded Unit) boundaries. The -copy all flag
copies metadata, which must then be updated to match the transformed image.
Install jpegtran in your PHP environment
# Debian/Ubuntu
sudo apt-get install libjpeg-progs libimage-exiftool-perl
# RHEL/CentOS (ExifTool may require an additional distribution repository)
sudo yum install libjpeg-turbo-utils perl-Image-ExifTool
# macOS (homebrew)
brew install jpeg exiftool
Verify the binary is available:
jpegtran -version
exiftool -ver
Check system requirements
Before you dive in, check the EXIF extension, jpegtran, and ExifTool. ExifTool resets the
orientation metadata after the pixels are transformed, preventing a second rotation in viewers.
<?php
function checkRequirements(): void
{
if (!extension_loaded('exif')) {
throw new RuntimeException('The EXIF extension is not enabled.');
}
foreach (['jpegtran', 'exiftool'] as $binary) {
$output = [];
exec('command -v ' . escapeshellarg($binary), $output, $status);
if ($status !== 0 || empty($output)) {
throw new RuntimeException($binary . ' is not installed or not in PATH.');
}
}
}
Read EXIF data with PHP
<?php
function getOrientation(string $file): int
{
if (!is_readable($file)) {
throw new RuntimeException("File {$file} is not readable.");
}
$exif = @exif_read_data($file);
if ($exif === false) {
// No EXIF block or unreadable — assume upright
// You might want to log a warning here if EXIF data was expected
return 1;
}
return (int) ($exif['Orientation'] ?? 1);
}
Call jpegtran from PHP safely
<?php
function rotateJpeg(string $src, ?string $dst = null): string
{
if (!is_readable($src)) {
throw new RuntimeException("Source file {$src} is not readable.");
}
$dst ??= 'rotated_' . basename($src);
if (file_exists($dst)) {
throw new RuntimeException('Choose a new destination; existing files are not overwritten.');
}
$src = realpath($src);
$orientation = getOrientation($src);
// Map EXIF orientation to jpegtran switch
$map = [
2 => '-flip horizontal', 3 => '-rotate 180', 4 => '-flip vertical',
5 => '-transpose', 6 => '-rotate 90', 7 => '-transverse', 8 => '-rotate 270',
];
if (!isset($map[$orientation])) {
// If orientation is 1 (normal) or any other unhandled value,
// copy the file as is.
if (!copy($src, $dst)) {
throw new RuntimeException("Failed to copy {$src} to {$dst}.");
}
return $dst;
}
$cmd = sprintf(
'jpegtran -perfect %s -copy all -outfile %s %s',
$map[$orientation],
escapeshellarg($dst),
escapeshellarg($src)
);
exec($cmd, $output, $status);
clearstatcache(true, $dst);
if ($status !== 0 || !is_file($dst) || filesize($dst) === 0) {
if (is_file($dst)) unlink($dst);
throw new RuntimeException('jpegtran could not perform a perfect lossless transform.');
}
// Remove the stale thumbnail and mark the transformed pixels as upright.
exec('exiftool -overwrite_original -Orientation=1 -n -ThumbnailImage= ' .
escapeshellarg(realpath($dst)), $metadataOutput, $metadataStatus);
if ($metadataStatus !== 0) {
unlink($dst);
throw new RuntimeException('Could not reset image orientation metadata.');
}
return $dst;
}
Batch processing multiple images
-perfect refuses transforms that cannot preserve all edge pixels at JPEG block boundaries.
Handle those failures deliberately with a separate re-encoding workflow. Work in a private output
directory, and never pass the source file as the destination.
If you have multiple images to process, you can loop through them and apply the rotation logic. Here's a basic example:
<?php
function batchRotateImages(array $sourceFiles, string $destinationDir): array
{
if (!is_dir($destinationDir) || !is_writable($destinationDir)) {
throw new RuntimeException("Destination directory {$destinationDir} is not writable or does not exist.");
}
$processedFiles = [];
foreach ($sourceFiles as $srcFile) {
try {
$baseName = basename($srcFile);
$dstFile = $destinationDir . DIRECTORY_SEPARATOR . 'rotated_' . $baseName;
$processedFiles[$srcFile] = rotateJpeg($srcFile, $dstFile);
} catch (RuntimeException $e) {
// Log error for this specific file and continue with others
error_log("Failed to process {$srcFile}: " . $e->getMessage());
$processedFiles[$srcFile] = false; // Indicate failure
}
}
return $processedFiles;
}
// Example usage:
// $filesToProcess = ['image1.jpg', 'path/to/image2.jpg'];
// $outputDirectory = 'processed_images';
// if (!is_dir($outputDirectory)) {
// mkdir($outputDirectory, 0755, true);
// }
// $results = batchRotateImages($filesToProcess, $outputDirectory);
// print_r($results);
Make sure the destination directory exists and is writable by your PHP script.
Complete working example with error handling
<?php
// Ensure these functions are defined or included from where they are declared above.
// function checkRequirements(): void { ... }
// function getOrientation(string $file): int { ... }
// function rotateJpeg(string $src, string $dst = null): string { ... }
// function batchRotateImages(array $sourceFiles, string $destinationDir): array { ... }
try {
checkRequirements(); // Checks for EXIF extension and jpegtran
$sourceFile = 'photo.jpg'; // Ensure this file exists for testing
if (!file_exists($sourceFile)) {
// Create a dummy file for testing if it doesn't exist
// In a real scenario, ensure photo.jpg is a valid JPEG with EXIF data.
if (!touch($sourceFile)) {
error_log("Warning: Could not create dummy file {$sourceFile}. Ensure the directory is writable.");
} else {
error_log("Warning: {$sourceFile} does not exist. A dummy file was touched for the example to run. Rotation may not occur as expected without valid EXIF data.");
}
}
$fixed = rotateJpeg($sourceFile, 'rotated_photo.jpg');
echo "Saved correctly oriented file to $fixed\n";
// Example for batch processing (optional, uncomment to test)
/*
$filesToProcess = [$sourceFile]; // Add more files as needed
$outputDirectory = 'processed_batch';
if (!is_dir($outputDirectory)) {
if (!mkdir($outputDirectory, 0755, true)) {
throw new RuntimeException("Could not create directory: {$outputDirectory}");
}
}
echo "\nStarting batch processing...\n";
$results = batchRotateImages($filesToProcess, $outputDirectory);
print_r($results);
echo "Batch processing finished. Check the '{$outputDirectory}' directory.\n";
*/
} catch (Throwable $e) {
error_log("Error: " . $e->getMessage());
// It's generally better to let PHP handle the response code
// or set it based on the context (e.g., web request vs. CLI script)
// http_response_code(500);
echo "An error occurred. Check the error log for details.\n";
}
The three functions (checkRequirements, getOrientation, and rotateJpeg) cover dependency
checks, EXIF parsing, command execution, metadata updates, and output validation.
Security considerations with exec
- Escape every argument with
escapeshellarg()—never concatenate raw user input. This is crucial to prevent command injection vulnerabilities. - Validate dependencies (
command -v jpegtran) before calling them. Ensurejpegtranis installed and accessible in the system's PATH. - Check command success through the exit-status argument passed to
exec(), then verify that the resulting file exists and has a non-zero size. - Limit write locations to directories outside the public web root when possible, and ensure proper file permissions for read and write operations.
- Sanitize file paths: Ensure that input file paths and output destinations are validated and sanitized to prevent directory traversal attacks or writing to unintended locations.
Handle edge cases and error scenarios
- Missing EXIF data – The
getOrientationfunction defaults to orientation 1 (normal), and in such cases,rotateJpegwill copy the file as-is. - Unreadable source file – The
getOrientationandrotateJpegfunctions now include checks usingis_readable()and throw exceptions if a file cannot be read. jpegtrancommand failure:rotateJpegchecks the exit status and output file, removes failed output, and throws an exception.- Progressive JPEGs:
jpegtranaccepts progressive input. Perfect-transform restrictions still apply, and progressive output requires the-progressiveoption. - Large images: Coefficient buffers still consume memory. Apply resource limits and keep sufficient disk space for output, especially when processing concurrent jobs.
- Unsupported orientation values – The current code copies the image as-is if the EXIF orientation value is 1 or unrecognized. You could modify it to throw an exception for unsupported values if strict handling is required.
- Permission errors – Ensure the PHP script has read permissions for source files and write
permissions for the destination directory. The
is_readable()checks help, and file operation failures (likecopy()orjpegtranoutput redirection) will typically result in exceptions or failed output checks. - File cleanup: If temporary files are created or if original files need to be removed after
successful processing, implement a cleanup mechanism. The current examples create new files (e.g.,
rotated_photo.jpg), so explicit cleanup of originals might be needed depending on your workflow.
Why jpegtran excels for JPEG rotation
- Lossless: It transforms quantized coefficients without another lossy encoding pass.
- Metadata-friendly –
-copy allkeeps EXIF, XMP, and ICC profiles. - Often faster: It avoids a full pixel-domain decode and re-encode, but benchmark your own workload to be sure.
- Stable – Part of libjpeg for decades and available on virtually every server platform.
Wrap-up
With a handful of lines you can read the EXIF orientation, call jpegtran, and hand your users an
upright photo—without losing a single bit of quality. Drop the functions above into your upload
handler or queue worker, and sideways selfies will be a thing of the past.
At Transloadit, we use advanced image optimization techniques in our 🤖 /image/optimize robot, which supports JPEG, PNG, GIF, WebP and SVG formats with configurable optimization priorities.
