lychee-org/php-exif
A PHP library for reading, writing, and manipulating EXIF metadata in images. It provides an object-oriented API to extract camera, GPS, and exposure details or edit and remove metadata, making it useful for photo galleries, media managers, and image-processing workflows.
Install
composer require lychee-org/php-exif
No service provider is auto-discovered and there is no config file — this is a plain library. You wire it up yourself.
Where to look first
src/Reader/Reader.php — the entry point (Reader::factory(...), ->read($path)).src/Adapter/ — the actual implementations. The native Exif adapter wraps PHP's exif_read_data(); the FFProbe adapter shells out to ffprobe for video containers. Whatever adapters exist in your installed version is what you can actually use — read this directory before designing around a format.src/Exif.php — the value object returned by the reader. Getters like getCameraMake(), getCameraModel(), getDateTimeOriginal(), getOrientation(), getWidth(), getHeight(), getLatitude(), getLongitude(), getKeywords(), getMimeType(). Check the file for the exact getter list in your version; the Lychee fork adds getters the upstream package doesn't have, and every getter can return null.First use case — capture metadata on upload
use PHPExif\Reader\Reader;
$reader = Reader::factory(Reader::TYPE_NATIVE);
try {
$exif = $reader->read($request->file('photo')->getRealPath());
} catch (\Throwable $e) {
$exif = null; // never let missing EXIF break an upload
}
$takenAt = $exif?->getDateTimeOriginal();
The reader takes a local filesystem path, not a stream and not an UploadedFile. UploadedFile::getRealPath() and Storage::disk('local')->path($p) work; Storage::disk('s3') does not — copy to a temp file first.
Wrap it in one injectable service. Don't call Reader::factory() from controllers. Bind a small ExifExtractor as a singleton (or scoped) in a service provider and return a plain array/DTO — that keeps the third-party namespace out of your app and makes it swappable later.
final class ExifExtractor
{
public function extract(string $absolutePath): array
{
$reader = Reader::factory(Reader::TYPE_NATIVE);
$exif = $reader->read($absolutePath); // throws; caller decides
return array_filter([
'camera_make' => $exif->getCameraMake(),
'camera_model' => $exif->getCameraModel(),
'taken_at' => $exif->getDateTimeOriginal(),
'orientation' => $exif->getOrientation(),
'width' => $exif->getWidth(),
'height' => $exif->getHeight(),
'lat' => $exif->getLatitude(),
'lng' => $exif->getLongitude(),
], fn ($v) => $v !== null);
}
}
Run it in a queued job, after the file is persisted. EXIF parsing touches disk and can shell out (FFProbe), so it does not belong in the request/response cycle. Store the file, dispatch ExtractExif($mediaId), and write results back onto the model.
Persist as a JSON column, not fifty columns. EXIF key sets are wildly inconsistent across cameras. Store the normalized array in a json/jsonb column plus a handful of promoted, indexed columns (taken_at, camera_model, lat, lng) that you actually query on.
Adapter chaining with fallbacks. Pick the adapter by MIME type rather than by trial and error: native EXIF for JPEG/TIFF, FFProbe for MP4/MOV, and a no-op for anything else. Wrap in try/catch (\Throwable) and treat "no metadata" as a normal outcome — screenshots, exported PNGs, and images re-saved by a messaging app have had their EXIF stripped.
Timestamp fallback ladder. Prefer getDateTimeOriginal(), then file mtime, then now(). Never let a null date block the model from being created.
Normalize orientation before storing derivatives. The reader reports getOrientation() (1–8) but does not rotate anything. Apply it with Intervention Image / GD when generating thumbnails, and strip EXIF on the derivative so browsers don't double-rotate.
GPS into a spatial column. For PostGIS/MySQL spatial, convert lat/lng to a point after validating ranges: latitude ∈ [-90, 90], longitude ∈ [-180, 180]. Reject anything else as garbage metadata.
Testing. Use Storage::fake('local') and a handful of small committed fixture JPEGs with known EXIF (one with GPS, one without, one rotated). Assert on your ExifExtractor output, not on the library's object, so a dependency swap doesn't rewrite your test suite.
storage_path('app/tmp/...'), parse, delete in a finally.exif_read_data() must be enabled. Some hardened PHP builds and minimal Docker images compile it out or disable it via disable_functions. Guard with function_exists('exif_read_data') and fail open.ImageDescription routinely contain newlines, control bytes, and non-UTF-8 sequences that will blow up json_encode (silently returning false) or a Blade render. Sanitize, truncate to sane lengths, and encode with JSON_INVALID_UTF8_SUBSTITUTE.DateTimeOriginal has no timezone. It's floating local time at the camera. If OffsetTimeOriginal is present you can anchor it; otherwise store it without assuming UTC, or your "taken at" ordering will drift across a user's travel history.(0, 0) is ambiguous — it's both "no GPS" and a real point in the Gulf of Guinea. Check whether the getter returns null versus 0.0 and gate on null explicitly; don't use empty().null, including things that feel mandatory. getWidth()/getHeight() can be missing on valid files. Type your consumers as nullable and give every field a fallback.\Throwable at the boundary and log at debug — an upload without EXIF is not an error.php:8.x-fpm images. Either install it or disable that adapter; configure the binary path explicitly via the adapter constructor rather than relying on PATH.composer.json, keep the library behind your own service class, and expect that you may eventually swap in exiftool or a hand-rolled exif_read_data() call without touching call sites.exiftool JSON wrapper, a pure-PHP HEIC parser, or a metadata microservice — and keeps the Reader API unchanged. That's the seam to use if the built-in adapters don't cover your formats.How can I help you explore Laravel packages today?