wapmorgan/file-type-detector
A lightweight PHP library that detects file types by extension and MIME type, mapping files to broad categories such as image, audio, video, document, archive, and other. Useful for upload validation, file organization, and content handling in PHP applications.
This is a small, dependency-free PHP library — not a Laravel package. There is no service provider, facade, config file, or Artisan command. You wire it in yourself.
Install
composer require wapmorgan/file-type-detector
Where to look first
README.md — the entire API surface is two or three methods.src/Detector.php (the main class) — confirm the exact namespace/class name; it's wapmorgan\FileTypeDetector\Detector and exposes extension-based and content-based detection.Minimal usage
use wapmorgan\FileTypeDetector\Detector;
$detector = new Detector();
// By extension (never opens the file)
$mime = $detector->detectByFilename('report.pdf'); // application/pdf
// By content (reads the leading bytes / magic number)
$mime = $detector->detectByContent('/tmp/phpA1b2C3'); // application/pdf
First real use case: stop trusting UploadedFile::getClientMimeType() (browser-supplied, trivially spoofed) and getMimeType() (relies on the fileinfo extension, which is disabled on plenty of shared hosts). Detect by content on the uploaded temp file, compare it against the client's original extension, and reject mismatches.
$contentMime = (new Detector())->detectByContent($request->file('avatar')->getRealPath());
Register it once so you're not new Detector() in every controller — the signature/extension tables get loaded per instance:
// AppServiceProvider::register()
$this->app->singleton(\wapmorgan\FileTypeDetector\Detector::class);
1. A reusable validation rule
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use wapmorgan\FileTypeDetector\Detector;
class RealMimeType implements ValidationRule
{
public function __construct(private array $allowed) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$mime = app(Detector::class)->detectByContent($value->getRealPath());
if (! in_array($mime, $this->allowed, true)) {
$fail("The {$attribute} must be one of: " . implode(', ', $this->allowed) . " (detected: {$mime}).");
}
}
}
$request->validate([
'document' => ['required', 'file', 'max:10240', new RealMimeType(['application/pdf'])],
]);
2. Content-vs-extension mismatch check
The strongest signal this library gives you is disagreement between the two detectors. Normalize before comparing — the extension map and the signature map don't return identical strings for every format (image/jpg vs image/jpeg, text/json vs application/json).
$byExt = $detector->detectByFilename($file->getClientOriginalName());
$byContent = $detector->detectByContent($file->getRealPath());
if ($byExt !== null && ! $this->sameFamily($byExt, $byContent)) {
// A .jpg that is really text/html is an XSS payload, not a JPEG.
throw ValidationException::withMessages(['file' => 'File contents do not match its extension.']);
}
3. Extension-based routing without opening the file
For naming/classification logic (icons, previews, "is this an image?") you can skip I/O entirely:
$mime = $detector->detectByFilename($asset->original_name);
$bucket = str_starts_with($mime ?? '', 'image/') ? 'images'
: (str_starts_with($mime ?? '', 'video/') ? 'videos' : 'other');
4. Media-library / batch import job
Queued job iterating stored files. Detect on the local path, not an S3 URL:
foreach (Storage::disk('public')->allFiles() as $path) {
$mime = $detector->detectByContent(Storage::disk('public')->path($path));
MediaAsset::updateOrCreate(
['path' => $path],
['mime_type' => $mime]
);
}
5. Serving a correct Content-Type
Storage::response() already guesses, but when you're streaming from S3 or a custom disk you can fill the gap:
return response()->streamDownload(
fn () => readfile(Storage::path($file->path)),
$file->name,
['Content-Type' => $detector->detectByContent(Storage::path($file->path)) ?? 'application/octet-stream']
);
6. Fallback chain with Symfony Mime
Best practice in a modern Laravel app: use symfony/mime (already a transitive dependency, backed by fileinfo) as the primary, and this library as the pure-PHP fallback for hosts without fileinfo:
$mime = (new MimeTypes())->guessMimeType($path)
?? app(Detector::class)->detectByContent($path)
?? 'application/octet-stream';
7. Whitelist by family, not by exact string
Since the library's maps are dated and occasionally return legacy aliases, validate against a family:
$allowed = ['image/jpeg', 'image/png', 'image/webp'];
$ok = in_array($mime, $allowed, true) || in_array($mime, ['image/jpg', 'image/pjpeg'], true);
It's unmaintained since 2017. No release in ~8 years. It still installs and runs on PHP 8.x in practice (it's plain arrays and string functions), but pin it, run your test suite on your target PHP version, and keep a fork in mind. Do not expect fixes for new formats — AVIF, HEIC/HEIF, JPEG XL, and newer WebP variants are almost certainly absent or misidentified, so guard any image pipeline that accepts modern formats.
UploadedFile::getRealPath() has no extension. PHP's upload temp file is /tmp/phpXXXXXX. Calling detectByFilename() on it returns nothing useful. Always pass getClientOriginalName() for extension detection and getRealPath() for content detection — mixing them up is the #1 mistake.
ZIP-based formats collapse to application/zip. docx, xlsx, pptx, epub, odt, and JAR are all ZIP containers. Content detection sees PK\x03\x04 and stops. For Office documents you must combine the extension hint with the content result.
image/svg+xml is an XSS vector. SVG is XML; content detection may return text/xml or text/plain rather than image/svg+xml. Never serve user-uploaded SVGs inline from your own origin, whatever MIME you get back.
This is not a security boundary. Magic-byte sniffing reads a prefix of the file. Polyglots (valid GIF header followed by HTML/JS), truncated files, and crafted headers all defeat it. Treat detection as one layer; add Content-Disposition: attachment, X-Content-Type-Options: nosniff, and CSP on user content.
Normalize case. Extensions arrive as .JPG, .Jpg, or with double extensions (.jpg.php). Lowercase the name yourself before passing it in, and use pathinfo($name, PATHINFO_EXTENSION) from the last dot — the library looks at the final extension, which is what you want for .jpg.php.
Unknown types return null/false (not an exception). Wrap comparisons accordingly; don't str_starts_with() on a null without a null-coalesce.
No extension point, so plan to fork or wrap. The MIME/signature tables are static arrays inside the source. If you need to add a format, either maintain your own small override map in front of the library:
$overrides = ['avif' => 'image/avif', 'heic' => 'image/heic'];
$mime = $overrides[strtolower($ext)] ?? $detector->detectByFilename($name);
…or vendor the class and edit the arrays directly. There is no config, no DI, no events.
Debugging: always print three values side by side — detectByContent(), detectByFilename(), and PHP's finfo_file() / mime_content_type(). Divergence tells you whether it's a map gap (this library wrong) or a genuinely ambiguous file.
dump([
'content' => $detector->detectByContent($path),
'extension' => $detector->detectByFilename($name),
'finfo' => (new \finfo(FILEINFO_MIME_TYPE))->file($path),
]);
Large files are fine — content detection reads only the header bytes rather than slurping the file, so a 2 GB upload won't blow memory. Extension detection does no I/O at all.
Don't call new Detector() in a loop. The maps are rebuilt per instance; the singleton binding above matters on batch imports of thousands of files.
Given 0 dependents and 33 stars, assume no community answers exist. Read src/Detector.php end to end — it's small enough to audit in ten minutes, and you'll know exactly which formats you can and cannot rely on before you ship.
How can I help you explore Laravel packages today?