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:
$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
sidesteps re-encoding. It rearranges the compressed MCU (Minimum Coded Unit) blocks so
the pixels stay in their original form—bit-for-bit—after rotation. It also supports progressive
JPEGs, keeps Huffman tables untouched, and, with the -copy all
flag, preserves every EXIF entry.
Install Jpegtran in your PHP environment
# Debian/Ubuntu
sudo apt-get install libjpeg-progs
# Rhel/centos
sudo yum install libjpeg-turbo-utils
# macOS (homebrew)
brew install jpeg
Verify the binary is available:
jpegtran -version
Check system requirements
Before you dive in, make sure both the EXIF extension and jpegtran
are present:
function checkRequirements(): void
{
if (!extension_loaded('exif')) {
throw new RuntimeException('The EXIF extension is not enabled.');
}
$jpegtranPath = trim(shell_exec('command -v jpegtran')); // @phpstan-ignore-line
if ($jpegtranPath === '') {
throw new RuntimeException('jpegtran is not installed or not in PATH.');
}
}
Read exif data with 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
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);
$orientation = getOrientation($src);
// Map EXIF orientation to jpegtran switch
$map = [3 => 180, 6 => 90, 8 => 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;
}
$degrees = $map[$orientation];
$cmd = sprintf(
'jpegtran -rotate %d -copy all %s > %s',
$degrees,
escapeshellarg($src),
escapeshellarg($dst)
);
// Clear stat cache to ensure filesize check is accurate
clearstatcache();
shell_exec($cmd);
if (!is_file($dst) || filesize($dst) === 0) {
// You might want to check shell_exec's return value for errors too
throw new RuntimeException("jpegtran failed to produce output for {$src}. Command: {$cmd}");
}
return $dst;
}
Batch processing multiple images
If you have multiple images to process, you can loop through them and apply the rotation logic. Here's a basic example:
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
) together are fewer
than 70 lines yet cover installation checks, EXIF parsing, secure command execution, and output
validation.
Security considerations with shell_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. Ensurejpegtran
is installed and accessible in the system's PATH. - Check command success by verifying that the resulting file exists and has a non-zero size.
Also, consider checking the return value of
shell_exec
(though it can be tricky as it returns the full output string, and errors fromjpegtran
might go to stderr). - 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
getOrientation
function defaults to orientation 1 (normal), and in such cases,rotateJpeg
will copy the file as-is. - Unreadable source file – The
getOrientation
androtateJpeg
functions now include checks usingis_readable()
and throw exceptions if a file cannot be read. jpegtran
command failure –rotateJpeg
checks if the output file was created and is not empty. The error message now includes the command for easier debugging.- Progressive JPEGs –
jpegtran
rotates them perfectly; no extra flag required. - Large images – Rotation is memory-efficient because data stays compressed, but make sure you have disk space for the temporary output file. Consider monitoring disk space if processing very large batches.
- Unsupported orientation values – The current code copies the image as-is if the EXIF orientation value is not 3, 6, or 8. You could modify it to throw an exception for other 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()
orjpegtran
output 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 – The compressed data is never decoded, so pixels remain untouched.
- Metadata-friendly –
-copy all
keeps EXIF, XMP, and ICC profiles. - Often faster – Because no decompression happens, it can finish quicker than re-encoding, 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.