thecyrilcril / laravel-rekognition
AWS Rekognition faces, labels and text for Laravel, behind named config profiles, typed rules and a first-class fake.
Package info
github.com/thecyrilcril/laravel-rekognition
pkg:composer/thecyrilcril/laravel-rekognition
Requires
- php: ^8.3
- aws/aws-sdk-php: ^3.390
- illuminate/contracts: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.24
- mockery/mockery: ^1.6
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0|^5.0
- phpstan/phpstan: ^2.1
README
AWS Rekognition faces, labels and text for Laravel, behind named config profiles, typed rules and a first-class fake.
The package wraps three Rekognition operations — DetectFaces, DetectLabels, DetectText — and turns each raw response into a small verdict object that already knows whether the image passed your rules and why not. Rules live in named profiles in config (passport, avatar, proof, …), can be overridden per call, and are evaluated identically in production and in the test fake.
It does three jobs:
- Faces — is this a usable portrait? One face, facing the camera, eyes open, no sunglasses, lit, sharp, framed, not smiling. Nine reasons, each with a plain-language message you can show to the person who took the photo.
- Labels — what is in this scene, and is the image bright and sharp enough to trust? Required-any-of and forbidden label lists, with parent-taxonomy matching.
- Text — what does the sticker say, and does it match what we expected? Confidence filtering and OCR-friendly normalisation (
0/O,1/I,8/B, case, whitespace).
Installation
composer require thecyrilcril/laravel-rekognition
Publish the config file:
php artisan vendor:publish --tag=rekognition-config
Set the region and, if you are not on an instance role, the credentials in .env:
AWS_REKOGNITION_REGION=eu-west-1 AWS_REKOGNITION_KEY= AWS_REKOGNITION_SECRET=
The key and secret fall back to AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, and the region to AWS_DEFAULT_REGION. When both halves of the key pair are empty the SDK's default provider chain (instance role, env, shared profile) is used instead. A half-configured pair is treated as absent. The package requires the gd and fileinfo PHP extensions for image pre-validation.
Quick start
Every call takes a local path or an ImageSource; the bytes are read once and sent inline. Before the AWS call the image is pre-validated (JPEG or PNG, at most 5 MB, decodable), and any failure there throws InvalidImage without spending a request.
Faces
use Thecyrilcril\Rekognition\Exceptions\InvalidPhoto; use Thecyrilcril\Rekognition\Facades\Rekognition; try { $verdict = Rekognition::faces()->profile('passport')->validate($path); } catch (InvalidPhoto $exception) { return back()->withErrors(['photo' => $exception->getMessage()]); }
validate() throws InvalidPhoto for the first failing rule. analyze() returns the verdict instead and never throws on a rule failure:
$verdict = Rekognition::faces()->profile('avatar')->analyze($path); $verdict->passes(); // bool $verdict->reasons(); // list<Reason>, in check order $verdict->faceCount; // int $verdict->face?->sharpness; // the first face, or null $verdict->faceWidthRatio(); // percentage of the frame $verdict->toArray(); // store this
Labels
use Thecyrilcril\Rekognition\Facades\Rekognition; $verdict = Rekognition::labels()->profile('proof')->analyze($path); $verdict->passes(); // quality + required/forbidden rules $verdict->has('Trash Can', 80.0); // a label at or above this confidence $verdict->best('Trash Can')?->confidence; $verdict->quality->brightness; // whole-frame quality, 0–100 $verdict->labels; // list<DetectedLabel>
Text
use Thecyrilcril\Rekognition\Facades\Rekognition; use Thecyrilcril\Rekognition\Results\TextOutcome; $verdict = Rekognition::text()->expect('BIN0042')->analyze($path); $verdict->outcome === TextOutcome::Matched; // after normalisation $verdict->lines(); // list<string> $verdict->words(); // list<string>
Without expect() the outcome is NotCompared when anything was read and Unread when nothing cleared the confidence threshold.
Configuration
Profiles
A profile is a named set of rule values under rekognition.profiles.{faces|labels|text}.{name}. The shipped config defines faces.passport, faces.avatar, labels.proof and text.default; add your own alongside them. Keys are snake_case and map one-to-one onto the rule value objects in src/Rules:
| Capability | Keys |
|---|---|
faces |
min_sharpness, min_brightness, max_pose_deviation, face_ratio_min, face_ratio_max, smile_confidence, allow_smile, allow_sunglasses, require_single_face |
labels |
min_label_confidence, required_any_of, forbidden, max_labels, min_image_brightness, min_image_sharpness |
text |
min_confidence, expected, normalise_zero_o, normalise_one_i, normalise_eight_b, normalise_case, normalise_whitespace |
A profile is validated the first time it is used, not at boot. A percentage outside 0–100, a pose outside 0–180, a non-boolean switch, a face_ratio_min above face_ratio_max, or an unknown key throws InvalidProfile naming the profile and the field. A profile name that does not exist throws UnknownProfile listing the ones that do. A profile that is never used never throws.
Environment keys
Every numeric value in the shipped profiles is env-overridable with an explicit cast, for example REKOGNITION_PASSPORT_MIN_SHARPNESS, REKOGNITION_AVATAR_MAX_POSE_DEVIATION, REKOGNITION_PROOF_MIN_IMAGE_BRIGHTNESS and REKOGNITION_TEXT_MIN_CONFIDENCE. See config/rekognition.php for the full list. Cast in config: the rules reject numeric strings.
Per-call overrides
with() takes named arguments matching the rule fields and returns a new analyzer with those values replaced:
Rekognition::faces()->profile('passport')->with(allowSmile: true)->validate($path); Rekognition::labels()->with(requiredAnyOf: ['Bin', 'Waste Container'], minImageSharpness: 40.0)->analyze($path); Rekognition::text()->with(minConfidence: 90.0, normaliseCase: false)->expect('ABC 123')->analyze($path);
Resolution order is defaults → profile → with(): the built-in defaults (the constructor values in src/Rules) apply until profile() replaces them wholesale, and with() then replaces only the fields you name. profile(null) goes back to the defaults. Every call returns a new instance; nothing held by the container is mutated.
rules() returns the rule set the analyzer will apply — defaults, then the profile, then with() — for when your own code needs the same thresholds:
$analyzer = Rekognition::labels()->profile('proof'); $verdict = $analyzer->analyze($path); $minimum = $analyzer->rules()->minLabelConfidence;
Results and exceptions
Verdicts are readonly value objects. Each has passes(), reasons() (a list of Results\Reason enum cases, in check order) and toArray(). Reason values are snake_case tokens meant for JSON columns and audit rows.
Every exception the package raises extends Thecyrilcril\Rekognition\Exceptions\RekognitionException, so one catch covers them all:
| Exception | When |
|---|---|
InvalidImage |
Pre-validation failed before any AWS call: unreadable, not JPEG/PNG, over 5 MB, undecodable |
InvalidPhoto |
A face rule failed on validate(); reason() returns the Reason |
InvalidProfile |
A profile in config has a bad value or an unknown key |
UnknownProfile |
profile() was given a name that is not configured |
TransportFailed |
The AWS call itself failed (network, throttling, auth, region); the SDK exception is getPrevious() |
The nine face reasons, in the order they are checked:
| Reason | Message shown to the user |
|---|---|
no_face_detected |
No face detected in the photo. |
multiple_faces_detected |
Multiple faces detected — please upload a photo with only one face. |
sunglasses_detected |
Please remove sunglasses before taking the photo. |
not_facing_camera |
Please upload a photo with your face looking directly at the camera. |
eyes_closed |
Please keep both eyes open in the photo. |
poor_lighting |
Photo is too dark or unclear — please improve lighting. |
too_blurry |
Photo is too blurry — please upload a clearer photo. |
improper_framing |
Please upload a photo with your face covering 35%–70% of the frame. (bounds from the profile) |
smiling_detected |
Please do not smile in the photo. |
Face direction is checked before eyes because Rekognition's eyes-open reading is unreliable when one eye is hidden by a profile pose. Labels add image_too_dark, image_too_blurry, expected_labels_missing and forbidden_label_present; text adds text_unread and text_mismatched.
Testing
Swap in the fake with Rekognition::fake(), seed the raw AWS-shaped response you want the next call to see, then assert on what was analysed. The fake uses the real profiles, the real rules and the real verdict mapping, so the rule evaluation your test exercises is the one production runs. It never needs a real image file: the path is recorded as given, the probe is skipped, and a 1000×1000 frame is assumed for ratio maths.
use Thecyrilcril\Rekognition\Facades\Rekognition; use Thecyrilcril\Rekognition\Testing\RecordedCall; it('accepts a proof photo that shows a bin', function () { $fake = Rekognition::fake(); $fake->seedLabels([['Name' => 'Trash Can', 'Confidence' => 95]]); $verdict = Rekognition::labels()->profile('proof')->analyze('/proof/photo.jpg'); expect($verdict->has('Trash Can'))->toBeTrue(); $fake->assertLabelsAnalyzed(fn (RecordedCall $call) => $call->profile === 'proof'); });
Seeding:
| Method | Queues |
|---|---|
seedFaces(array $faceDetails) |
One DetectFaces response; a list of AWS FaceDetails entries |
seedLabels(array $labels, array $quality = []) |
One DetectLabels response; Labels entries plus ImageProperties.Quality (Brightness, Sharpness, Contrast — anything omitted defaults to 100) |
seedText(array $textDetections) |
One DetectText response; a list of AWS TextDetections entries |
failWith(RekognitionException $e) |
The next call of any capability throws this (TransportFailed, InvalidImage, …); the call is still recorded |
Seeds are first-in first-out per capability. A drained queue answers with an empty response — zero faces (so no_face_detected), no labels, no text — rather than throwing.
Assertions, each with a named failure message:
| Assertion | Checks |
|---|---|
assertFacesAnalyzed(?callable $callback = null) |
At least one faces call was made; with a callback, at least one call satisfies it |
assertLabelsAnalyzed(?callable $callback = null) |
Same, for labels |
assertTextAnalyzed(?callable $callback = null) |
Same, for text |
assertNothingAnalyzed() |
No call of any capability was made |
Callbacks receive a RecordedCall with capability (faces, labels or text), path (null for raw bytes), bytes (the length handed in; 0 when the path does not exist) and profile (null when no profile was selected). calls() returns every recorded call.
When your own code needs the client injected rather than called through the facade, type-hint the Thecyrilcril\Rekognition\Contracts\RekognitionClient contract. The service provider binds it as a singleton and Rekognition::fake() swaps that same binding, so the fake reaches injected consumers too.
Region and privacy note
At the time of writing Rekognition is not available in af-south-1 (Cape Town). The nearest region is eu-west-1 (Ireland), which is the package default. Image bytes therefore leave the continent for every call; document that in your privacy notice.
AWS may store and use content processed by its AI services to improve them unless you opt out. If your photos include people, opt out at the organisation level with an AI services opt-out policy before sending production traffic.
Supported versions
| PHP 8.3 | PHP 8.4 | PHP 8.5 | |
|---|---|---|---|
| Laravel 12 + Pest 3 | Yes | Yes | No — Pest 3 does not run on PHP 8.5 |
| Laravel 12 + Pest 4 | Yes | Yes | Yes |
| Laravel 13 + Pest 3 | Yes | Yes | No — Pest 3 does not run on PHP 8.5 |
| Laravel 13 + Pest 4 | Yes | Yes | Yes |
| Laravel 13 + Pest 5 | No — Pest 5 needs PHP 8.4+ | Yes | Yes |
Pest 5 requires Laravel 13 and PHP 8.4 or newer, so there is no Laravel 12 + Pest 5 row. Every supported cell runs in CI; the 100% coverage gate runs on PHP 8.5 / Laravel 13 / Pest 4.
Footguns
Type-hint the contract, not the manager. Inject Thecyrilcril\Rekognition\Contracts\RekognitionClient. RekognitionManager is final and is not the bound singleton; a constructor that asks for it compiles, gets an auto-resolved unfaked instance, and hits AWS from your tests.
Rules are immutable. profile(), with() and expect() each return a new analyzer. $analyzer->with(allowSmile: true); on its own line changes nothing — assign or chain the result.
Verdict DTOs are values, not records. Store $verdict->toArray(); never serialise or cache the objects themselves. The array shape is the stable contract, and a cached object would pin a rule set that config has since changed.
The fake skips pre-validation. A path that does not exist, a PDF, or a 50 MB file all pass through Rekognition::fake(). Test your own InvalidImage handling with failWith(InvalidImage::tooLarge(...)) rather than by handing the fake a bad file.