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

  1. Escape every argument with escapeshellarg()—never concatenate raw user input. This is crucial to prevent command injection vulnerabilities.
  2. Validate dependencies (command -v jpegtran) before calling them. Ensure jpegtran is installed and accessible in the system's PATH.
  3. 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 from jpegtran might go to stderr).
  4. Limit write locations to directories outside the public web root when possible, and ensure proper file permissions for read and write operations.
  5. 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 and rotateJpeg functions now include checks using is_readable() and throw exceptions if a file cannot be read.
  • jpegtran command failurerotateJpeg checks if the output file was created and is not empty. The error message now includes the command for easier debugging.
  • Progressive JPEGsjpegtran 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 (like copy() or jpegtran 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.

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.