mauricesvay/php-facedetection
Pure-PHP face detection library for images. Scans photos for frontal faces and returns bounding-box coordinates, with no OpenCV or external binaries required. Useful for cropping avatars, tagging photos, or adding basic image analysis to Laravel/PHP apps.
mauricesvay/php-facedetection is a single-class, pure-PHP Haar-cascade face detector that depends only on GD. It returns axis-aligned boxes for detected faces, which you then crop, blur, or tag yourself. The newly published 0.1.0 adds a cropFaceToJpeg() convenience method, which removes the most common boilerplate — but does not change anything about the detector's speed, accuracy, or licensing. Before writing a line of code, know two things: the license is GPL-2.0 (see Gotchas) and the upstream code is archived since 2016, so treat 0.1.0 as a pinned snapshot you own rather than a maintained dependency that will keep receiving fixes.
1. Verify GD is available
php -m | grep -i gd
php -r "var_dump(function_exists('imagecreatefromjpeg'), function_exists('imagecrop'));"
GD is usually compiled in, but if imagecreatefromjpeg is missing you have a stripped build and the package is a non-starter. imagecrop matters too now: cropFaceToJpeg() almost certainly delegates to it.
2. Pin the 0.1.0 tag
If the re-published tag ships a composer.json, take it as a VCS repository and pin the exact tag — do not float a constraint on a pre-1.0 package with a single maintainer:
"repositories": [
{ "type": "vcs", "url": "[email protected]:your-org/php-facedetection.git" }
],
"require": { "mauricesvay/php-facedetection": "0.1.0" }
If it still has no Composer metadata (upstream never did), keep the classmap route:
app/Support/FaceDetection/
├── FaceDetector.php
└── detection_cascade.xml # the cascade that ships next to the class
// composer.json
"autoload": { "classmap": ["app/Support/FaceDetection/"] }
composer dump-autoload
The class lives in the global namespace, so reference it as \FaceDetector or alias it: use \FaceDetector as PhpFaceDetector;.
3. Where to look first
FaceDetector.php — still short. Read faceDetect() for the return shape (['x' => int, 'y' => int, 'w' => int]) and cropFaceToJpeg() for its exact signature. The release note names the method but not its arguments, return value, JPEG quality, or whether it writes to disk or returns a binary string — confirm from source (or ReflectionMethod::getParameters()) before wiring it in.new FaceDetector($imagePath, $cascadeFile = null)).4. First use case: avatar cropping on profile-photo upload
use \FaceDetector;
$path = Storage::path($media->path); // absolute path, not a URL
$detector = new FaceDetector($path);
$faces = $detector->faceDetect(); // [] when nothing is found
if ($faces) {
$face = $faces[0]; // already sorted by size in most versions
// 0.1.0: one call instead of open/crop/encode.
// Verify argument order + whether it writes a file or returns a JPEG string.
$detector->cropFaceToJpeg($face, Storage::path("avatars/{$media->id}.jpg"));
}
Run this once, manually, on a single uploaded photo before building anything else. You will immediately see the three constraints that shape the whole integration: it is slow on full-size camera images, it returns a box with no height, and cropFaceToJpeg() only fixes the cropping, not the detection cost — it still detects on whatever image you handed the constructor.
Wrap it behind a contract immediately. The package's age and license mean you may need to swap it out; do not let \FaceDetector leak into controllers. Keep the contract box-shaped (portable) and treat cropFaceToJpeg() as an adapter-level optimisation, not part of the interface:
interface FaceDetectorContract
{
/** @return array<int, array{x:int,y:int,w:int}> */
public function detect(string $absolutePath): array;
}
class PhpFaceDetector implements FaceDetectorContract
{
public function detect(string $absolutePath): array
{
return (new \FaceDetector($absolutePath))->faceDetect();
}
/** Convenience for callers that only want the avatar. */
public function cropToJpeg(string $absolutePath, array $face, string $out): void
{
(new \FaceDetector($absolutePath))->cropFaceToJpeg($face, $out);
}
}
Bind the interface in a service provider and inject it everywhere. Your tests then bind a fake that returns canned boxes, so the suite never touches GD. Anything calling cropToJpeg() should be covered by one integration test against a fixture image, not the unit suite.
Detect in a queued job, never in the request cycle. Chain detection into your existing image pipeline:
class DetectFaces implements ShouldQueue
{
public int $timeout = 120;
public int $tries = 1;
public function __construct(public Media $media) {}
public function handle(FaceDetectorContract $detector): void
{
$faces = $detector->detect(Storage::path($this->media->path));
$this->media->update(['faces' => $faces]); // JSON column
FaceDetected::dispatch($this->media, $faces);
}
}
Dispatch from an observer on created, or from your upload action via DetectFaces::dispatch($media)->onQueue('images'). Keeping detection on a dedicated queue protects your web requests from a CPU-bound pure-PHP loop.
Downscale before detection, then map the coordinates back. Still the single highest-leverage optimisation, and cropFaceToJpeg() does nothing to change it. Detection cost scales with pixel count; a 12 MP upload is roughly 20× slower than an 800 px working copy and adds nothing in accuracy.
$original = imagecreatefromjpeg($path);
[$ow, $oh] = [imagesx($original), imagesy($original)];
$target = 800; // longest edge
$scale = min(1, $target / max($ow, $oh));
$work = imagescale($original, (int) ($ow * $scale), (int) ($oh * $scale), IMG_BICUBIC);
$tmp = tempnam(sys_get_temp_dir(), 'fd') . '.jpg';
imagejpeg($work, $tmp, 90);
try {
$faces = (new \FaceDetector($tmp))->faceDetect();
} finally {
@unlink($tmp);
}
$faces = array_map(fn ($f) => [
'x' => (int) round($f['x'] / $scale),
'y' => (int) round($f['y'] / $scale),
'w' => (int) round($f['w'] / $scale),
], $faces);
Note the split: detect on the downscaled file, then crop from the original using mapped coordinates. cropFaceToJpeg() on a FaceDetector built from the downscaled path will give you the downscaled crop — acceptable for a 200 px avatar, wrong for a hero image.
Let cropFaceToJpeg() handle the boring case, not the interesting one. It is right for: avatar thumbnails, notification icons, a single face, JPEG output, default quality. It is wrong for: format control (PNG/WebP), quality tuning, padding or aspect-ratio changes, composite output, multiple faces per image, or privacy redaction work. Keep one clampBox() helper and your own imagecrop() path for those.
Normalise the box before you use it. Boxes can start at negative coordinates or extend past the edge; height must be derived from width. Verify whether the new method clamps internally — if it doesn't, clamp first.
function clampBox(array $face, int $imgW, int $imgH): array
{
$size = (int) min($face['w'], $imgW, $imgH);
return [
'x' => max(0, min((int) $face['x'], $imgW - $size)),
'y' => max(0, min((int) $face['y'], $imgH - $size)),
'w' => $size,
'h' => $size,
];
}
Cache by content hash. Detection is deterministic for a given file and cascade, so sha1_file($path) makes a good cache key. Store the boxes in a JSON column or cache entry and skip the work on re-uploads, retries, and edits that don't touch pixels. Cache the boxes, not the cropped file, so you can re-render at new sizes without re-detecting.
Compose with Intervention Image for the downstream work. Detection gives you coordinates; cropping, blurring for privacy redaction, and format conversion are better handled by intervention/image. Use cropFaceToJpeg() only where its defaults match your needs; draw overlays during debugging with imagerectangle() on a copy to confirm boxes line up.
Backfill with a chunked artisan command. Any table that gains a faces column after launch needs images:detect-faces --chunk=50 --queue. Because the library is CPU-bound, one worker per core and no more; oversubscribing just thrashes.
cropFaceToJpeg() is a convenience wrapper around the crop/encode step. The expensive part — the cascade scan — still runs on the image you passed to the constructor. Downscale, cap the working image, keep it on a queue.cropFaceToJpeg() contract before trusting it. The release note is one sentence. Check: argument order (box first or output path first?), does it write a file or return a JPEG string, what quality does it default to, does it accept a quality parameter, does it throw or silently return false on a bad box. Write one test with a fixture image and assert getimagesize() on the output.imagecrop().max_execution_time. If you need real throughput, that is the point to look at OpenCV bindings or a cloud vision API instead.--memory on the queue worker, and avoid loading the original twice — crop from the already-open resource.exif_read_data() or Intervention's orientate()), and make sure cropFaceToJpeg() is fed the same oriented file, not the raw upload.h in the results. Boxes are square by convention. Deriving height from width is right for faces, but if you add a padding margin, pad symmetrically so the aspect ratio stays square.[]. Design the UX around "no face found" (fallback to a centre crop) rather than surfacing a failure.exif_imagetype() or getimagesize() and switch on the constant; wrap the constructor in a try/catch and convert warnings with a scoped error handler.cropFaceToJpeg() composites onto black. Flatten to a truecolour image on a white background before detection.setCascadeFile(), built with base_path(). If the cascade is missing you generally get zero faces rather than an exception, which is a painful bug to chase.each(), create_function()), dynamic-property deprecations, and implicit float-to-int conversions. The new method is new code inside an old codebase — it may use imagecrop() (PHP 5.5+) and nullable types or typed properties that narrow your supported PHP range. Read the diff, don't assume. Run the detector against a fixture image in CI so a PHP upgrade cannot silently break it.\FaceDetector is easy to collide with. Alias on import and keep it inside a single adapter class.cropFaceToJpeg() too — the new method does not relicense anything and does not move the package out of copyleft. Serving a SaaS internally is usually fine, but shipping the application to customers, embedding it in an appliance, or distributing a binary that bundles it can oblige you to release source under GPL-2.0. Get a legal sign-off before this ships in anything distributed. For internal-only use, wrapping it behind your own interface keeps the option to swap in a permissively licensed alternative.0.1.0 means someone is packaging it again — confirm who, and whether the tag is a straight republish or a fork with fixes. No fixes, no guaranteed PHP 8+ support, no Composer metadata historically. Fork it into your org, pin your copy, and note in the fork README exactly what you changed and which tag you based on. Do not expect the cascade to be updated for modern camera hardware.FaceDetector to override the scale-step properties, and setCascadeFile() to point at a different cascade (only the frontal-face cascade ships — no profile detection). If you need more than that, you are better served by a wrapper contract than by patching the class.cropFaceToJpeg() directly, that mismatch shows up as a crop of empty wall instead of a face.How can I help you explore Laravel packages today?