thecyrilcril/laravel-rekognition

AWS Rekognition faces, labels and text for Laravel, behind named config profiles, typed rules and a first-class fake.

Maintainers

Package info

github.com/thecyrilcril/laravel-rekognition

pkg:composer/thecyrilcril/laravel-rekognition

Transparency log

Statistics

Installs: 48

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.1 2026-08-22 14:59 UTC

This package is auto-updated.

Last update: 2026-08-22 15:02:51 UTC


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.