alchemy/phpexiftool
PHP wrapper for ExifTool that lets you read, write, and edit metadata in images and media files. Offers a simple object-oriented API for extracting EXIF, IPTC, and XMP data, with batch processing support for Laravel or any PHP project.
The package is a thin driver around the exiftool binary; 90% of your bugs will be binary related, not PHP related. Install the binary first, then the library.
# Debian/Ubuntu (works in your Dockerfile too)
apt-get install -y libimage-exiftool-perl
# macOS
brew install exiftool
# verify — the wrapper needs a specific version floor
exiftool -ver
composer require alchemy/phpexiftool
Where to look first:
composer.json → confirm whether you got the 3.x or 4.x API. 3.x exposes Reader / Writer / RdfMapper; 4.x is a rewrite around a single long-lived Exiftool process (open() / read() / write() / execute() / close()). The two are not interchangeable — pin the version in composer.json and read the README for that tag, not master.src/PHPExiftool/Driver/Tag/ — thousands of generated tag classes (EXIF\IFD0\Model, XMP\XMP\Description, IPTC\Application2\Keywords). This is your tag dictionary; grep here instead of guessing tag names.src/PHPExiftool/Driver/Value/ — Mono and Multi wrappers for scalar vs. list values.First use case: strip GPS from user uploads, or extract EXIF into an Eloquent column. Both are read/write on a single file and take under a minute to wire up.
use PHPExiftool\Exiftool;
$exiftool = new Exiftool(new \Psr\Log\NullLogger(), '/usr/bin/exiftool');
$exiftool->open(); // starts `-stay_open` mode
$tags = $exiftool->read($absolutePath); // array keyed like "EXIF:Model", "Composite:GPSLatitude"
$exiftool->close();
In Laravel, bind it once so you're not paying process startup per call:
// AppServiceProvider::register()
$this->app->singleton(Exiftool::class, fn () => tap(
new Exiftool(app(LoggerInterface::class), config('services.exiftool.binary')),
fn (Exiftool $e) => $e->open()
));
Strip-on-upload middleware / observer. The most common production use. Read the file, check for Composite:GPSLatitude / GPSLongitude, then write empty values back. Do it in a queued job if uploads are large or bursty — exiftool writes are 50–500 ms each and are pure CPU+IO.
$bag = new MetadataBag();
$bag->add(new Metadata(
new PHPExiftool\Driver\Tag\EXIF\IFD0\GPSLatitude(),
new PHPExiftool\Driver\Value\Mono('')
));
$exiftool->write($path, $bag, ['-overwrite_original' => true]);
Extract-to-model on ingest. Read once, map a small whitelist into your model, discard the rest. Do not persist the full tag array — it's 200+ keys, half of them derived Composite: tags that will change on every exiftool upgrade and churn your diffs.
$takenAt = $tags['EXIF:DateTimeOriginal'] ?? null; // "2024:05:01 12:00:00"
$takenAt = $takenAt ? Carbon::createFromFormat('Y:m:d H:i:s', $takenAt) : null;
Batch reads over directory scans. For imports, prefer one execute('-r', '-json', $dir) over N read() calls. Each read() round-trips through the stdin/stdout pipe and the JSON decode is not the bottleneck — the per-command framing is. If you must loop, keep the process open across the whole loop and close() in a finally.
Integrate with filesystem disks by path, not by stream. The binary needs a real path on the local filesystem. For S3 disks, Storage::path() won't exist; download to storage/app/tmp/, process, upload back, delete. Wrap this in a job with tries = 3 and clean up temp files in finally — orphaned temp files are the #1 operational complaint with this pattern.
Write-then-re-read is your test oracle. Exiftool's write result is optimistic; assert on a subsequent read() rather than the write's return value.
Custom tags and anything not modelled by a Tag class go through raw passthrough — this is also the escape hatch when the generated tag class doesn't exist for a vendor-namespaced XMP field:
$exiftool->execute('-XMP-dc:Subject+=holiday', '-overwrite_original', $path);
Queued pipeline shape that scales:
ProcessImageMetadata job → read → persist whitelist → dispatch StripLocation.StripLocation → conditional write → Storage::copy to the public variant.$exiftool->open() / close() themselves; never share an instance across jobs (see below).-stay_open processes leak. The wrapper holds a child process and two pipes. If you don't close(), you get zombie exiftool processes and, in long-running workers, a slowly growing pipe buffer. Always try { ... } finally { $exiftool->close(); }, or bind with scoped() in Laravel so Octane resets it per request. Never make it a hard singleton in a queue worker — the binary can die mid-stream and every subsequent call hangs.Segmentation fault on exotic HEIC/CR3 files is real), there's no built-in timeout. Guard with pcntl_alarm or run the whole call inside a Process with a timeout for untrusted formats._original backup files. By default exiftool writes photo.jpg_original next to every modified file. Pass -overwrite_original (and -P to preserve the mtime) or you'll litter your storage/app tree and break Storage::size() assertions.Warning: / Error: entries while still exiting 0, especially with -m (ignore minor errors). Parse those keys and log them; otherwise corrupt writes pass silently.Composite: and System: tags are read-only and computed. Composite:ImageSize, Composite:GPSPosition, System:FileModifyDate cannot be written. Filter by group before you build a MetadataBag, or you'll get cryptic Tag 'X' is not writable failures.Y:m:d H:i:s with colons in the date. Feed exiftool an ISO string and it will silently store garbage that other tools mis-parse. Also set EXIF:OffsetTimeOriginal alongside DateTimeOriginal or your dates drift in every viewer that respects timezone.EXIF:Model; writes need the class (e.g. EXIF\IFD0\Model) so exiftool knows which IFD to target. When in doubt, pass the flat string form to execute() — -EXIF:Model=X and -XMP:Creator=Y.-n for numbers, -api QuickTimeUTC for video. Otherwise GPS coordinates come back as "51 deg 30' 0.00\" N" and QuickTime dates get interpreted as local time. Both are per-call flags — set them everywhere, including inside the wrapper's option array.héllo.jpg on a filesystem with a non-UTF8 locale fails with "file not found". Pass -charset filename=UTF8 (and set LANG=C.UTF-8 in your container) rather than debugging for an hour.composer dump-autoload -o gets slower and your autoloader gets fatter. OPcache helps; if you only use a handful of tags, execute() with raw -Tag=Value strings is measurably lighter than instantiating Tag objects.alchemy/phpexiftool image and the Debian package both need libimage-exiftool-perl; the pure-PHP library bundles nothing. Pin the binary version in your image and write a test that asserts exiftool -ver is within the range you tested — upstream exiftool changes tag output between releases and your Composite: keys will shift.execute() without argument-array form, never let a user supply -config, and process uploads in a worker with a resource limit rather than in the web request.Exiftool service provider, the error handling, and any bug fixes yourself. If you only need -json reads, shelling out to exiftool -json -n via symfony/process is ~30 lines and gives you the same result with zero dependency surface — the wrapper's value is really just the long-lived process and the typed tag classes.How can I help you explore Laravel packages today?