PHP Packagev8.2.0MIT License

Fingerprint

Fingerprint turns PHP data into a short, stable string. If the data is the same, the string is the same, even when array keys are in a different order. Use it to detect changes, find duplicates, or build cache and idempotency keys:

1use Pharaonic\Fingerprint\Fingerprint;
2 
3$fingerprint = Fingerprint::make($product); // store it
4Fingerprint::verify($product, $fingerprint); // true while nothing changed

It also verifies payment gateway and webhook signatures with Fingerprint::signature(). It works with any provider that signs ordered fields; see the Signature Examples for Paymob, Kashier and your own webhooks.

New here? Start with the Quick Start: three methods cover most uses.

Key Order Never Matters

Associative arrays and stdClass objects are ordered canonically at every level, so a reordered payload keeps its fingerprint.

Types Are Preserved

1, "1", 1.0 and true give four different fingerprints, and null is not the same as a missing key.

Path Rules

Ignore, select, trim, lowercase, round, sort or transform values at dot paths such as items.*.price.

Hashing and HMAC

SHA-256 by default, any algorithm PHP supports, HMAC secrets, hex, binary, base64 or URL-safe base64 output.

Ordered Signatures

Build the exact message a payment gateway or webhook signs, field by field, and verify it in constant time.

Files and Streams

Fingerprint large files and streams in chunks, without loading them into memory.

Quick Tip

Some fields change all the time, like updated_at. Leave them out with Fingerprint::for($data)->ignore('updated_at')->make().

#Installation

Install the package with Composer. There is nothing to register or configure.

#Requirements

  • PHP 8.2
  • ext-mbstring
  • ext-intl or symfony/polyfill-intl-normalizer, only if you use the unicode() rule

There are no other runtime dependencies.

#Composer Installation

Terminal
composer require pharaonic/php-fingerprint

Composer autoloads the Pharaonic\Fingerprint namespace.

Installation Complete

You're all set! Try Pharaonic\Fingerprint\Fingerprint::make(['id' => 7]), which returns a 64-character SHA-256 hex string.

#Upgrading from 8.0.x or 8.1.x

Require PHP 8.2 and update the package. Nothing else changes: no method was removed or changed, and every value the earlier versions accept keeps its fingerprint, so stored fingerprints still match.

  • From 8.0.x, you also get enum support (Enums) and the xxh* and murmur3* algorithms (Hashing & HMAC).
  • 8.2.x marks the HMAC secret as a sensitive parameter, so PHP hides it in every stack trace (Keeping Secrets Safe).

#Quick Start

You only need three methods to start. Everything else in these docs is optional.

#1. Make a Fingerprint

Fingerprint::make() turns an array or a value into a short string. The same data always gives the same string:

1use Pharaonic\Fingerprint\Fingerprint;
2 
3$fingerprint = Fingerprint::make(['id' => 7, 'name' => 'Ahmed']);
4// "2f6edddef0f7abe5b4300047c00b0622fcb4c2822e6c00e5d624015751bedf6d"

The order of keys doesn't matter, so these two give the same fingerprint:

1Fingerprint::make(['id' => 7, 'name' => 'Ahmed']) === Fingerprint::make(['name' => 'Ahmed', 'id' => 7]); // true

#2. Check If Data Changed

Store the fingerprint, then later check whether the data is still the same:

1if (Fingerprint::verify($product, $storedFingerprint)) {
2 // nothing changed
3}

#3. Ignore What Doesn't Matter

Some fields change all the time, such as updated_at. Leave them out with for() and ignore(), then call make():

1$fingerprint = Fingerprint::for($product)
2 ->ignore(['updated_at', 'views'])
3 ->make();

That's it. You now know enough for most uses.

#Simple Recipes

Only sync records that changed:

1$new = Fingerprint::for($product)->ignore('updated_at')->make();
2 
3if ($new !== $product['fingerprint']) {
4 // push the product, then save $new as its fingerprint
5}

Find duplicate rows in an import:

1$seen = [];
2 
3foreach ($rows as $row) {
4 $key = Fingerprint::make($row);
5 
6 if (isset($seen[$key])) {
7 continue; // a duplicate
8 }
9 
10 $seen[$key] = true;
11}

Verify a payment gateway callback or a webhook: Ordered Signatures explains how it works, and Signature Examples has complete examples you can copy, including Paymob and Kashier.

#Good to Know

  • 7 and '7' are different, and so are [1, 2] and [2, 1]. The next page explains why.
  • Builders never change: each method returns a new one, so you can keep a configured builder and reuse it with ->for($otherData).

Next: How Matching Works starts The Basics: when two values share a fingerprint, and how to see what gets hashed.

#How Matching Works

Read this when two values you expect to match get different fingerprints, or the other way round. It needs nothing beyond the Quick Start.

Before hashing, every value is written in a canonical form (format v1). Three rules cover almost every case: key order never matters, list order does, and types are kept.

#Key Order Never Matters

An associative array (a map) gives the same fingerprint whatever the order of its keys, at every level:

1Fingerprint::make(['b' => 1, 'a' => 2]) === Fingerprint::make(['a' => 2, 'b' => 1]); // true

#List Order Matters

An array whose keys are exactly 0, 1, 2, … in that order is a list, and its order is part of the data:

1Fingerprint::make([1, 2]) === Fingerprint::make([2, 1]); // false

[] is an empty list. When a list's order doesn't matter to you, Lists shows how to ignore it.

#Types Are Kept

1, '1', 1.0 and true are four different values, and so are 0, '0', 0.0, false, '', [] and null:

1Fingerprint::make(['id' => 7]) === Fingerprint::make(['id' => '7']); // false

An enum case is a type of its own. Status::Paid matches neither 'paid' nor 'Paid', and cases of two different enums never match, even when they have the same backed value:

1enum Status: string { case Pending = 'pending'; case Paid = 'paid'; }
2enum PaymentState: string { case Paid = 'paid'; }
3 
4Fingerprint::make(['status' => Status::Paid]) === Fingerprint::make(['status' => 'paid']); // false
5Fingerprint::make(Status::Paid) === Fingerprint::make(PaymentState::Paid); // false

Normalization shows how to treat some of them as equal.

#Null Is Not a Missing Key

['a' => null] and [] give different fingerprints. Filtering shows how to leave nulls out.

#Seeing What Gets Hashed

result()->normalized() returns the value exactly as it is fingerprinted, after every rule you added. The next pages use it to show what each rule does:

1Fingerprint::for(['name' => 'Ahmed', 'updated_at' => '2026-10-12'])
2 ->ignore('updated_at')
3 ->result()->normalized();
4// ['name' => 'Ahmed']

Debugging covers everything else result() holds.

#Edge Cases

You can skip this part on a first read and come back when a value surprises you.

Floats are encoded from their exact IEEE-754 bits, so 0.1 + 0.2 and 0.3 differ; use precision() to round them. -0.0 is the same as 0.0. INF and -INF are supported. NAN throws an UnsupportedValueException, because it is not equal to itself.

Strings are compared byte for byte. Binary data, NUL bytes and invalid UTF-8 are fine; only the string rules (lower(), unicode()…) need valid UTF-8 and reject anything else.

Keys: integer and string keys are different, so [1 => 'x'] and ['a' => 'x'] never collide. PHP itself casts the key '1' to the integer 1, so ['1' => 'x'] and [1 => 'x'] are the same array and share a fingerprint.

Maps and lists after filtering: a list stays a list after filters remove elements, and is re-indexed. A map stays a map, even when filters empty it, so it doesn't match []. A sparse array such as [1 => 'a', 0 => 'b'] is a map.

Objects and dates: stdClass is a map, the same as the equivalent associative array. Dates are compared by their UTC instant, with microseconds. Other objects are covered in Objects.

1Fingerprint::make((object) ['a' => 1]) === Fingerprint::make(['a' => 1]); // true

What is rejected: closures, resources, generators, NAN, objects that aren't supported, objects that contain themselves, and values nested deeper than the maximum depth throw an exception that names the path:

1Fingerprint::make(['x' => new DateTimeZone('UTC')]);
2// UnsupportedValueException: An object of class DateTimeZone cannot be fingerprinted at path "x".

Next: Filtering: choose which values take part in the fingerprint.

#Filtering

You already know ignore() from the Quick Start. This page adds selecting fields, dropping empty values, and ignoring by condition.

Filters decide which values take part in the fingerprint.

#ignore()

Leave out the values at one key or several:

1Fingerprint::for($order)->ignore('updated_at')->make();
2 
3Fingerprint::for($order)->ignore(['updated_at', 'views'])->make();

Keys can point inside nested arrays too, such as customer.updated_at. The next page covers that.

#only()

The opposite of ignore(): keep only the values at these keys, and everything under them:

1Fingerprint::for(['id' => 7, 'name' => 'Ahmed', 'views' => 120])
2 ->only(['id', 'name'])
3 ->result()->normalized();
4// ['id' => 7, 'name' => 'Ahmed']

The order of the keys never matters, and calling only() again adds more keys. When nothing matches at all, an array becomes an empty map. ignore() wins over only() when both match.

#ignoreNulls(), ignoreEmptyStrings(), ignoreEmptyArrays()

Leave out values that end up null, '' or an empty array. They check values from the deepest up, so an array emptied by other filters is removed too:

1Fingerprint::for(['a' => ['b' => null], 'c' => ''])
2 ->ignoreNulls()
3 ->ignoreEmptyArrays()
4 ->result()->normalized();
5// ['c' => '']

They are off by default because null and a missing key are different data.

#ignoreWhen()

When no fixed list of keys fits, leave out every value for which a callback returns true. The callback receives the value's dot path and the value itself, and must return a boolean:

1Fingerprint::for($payload)
2 ->ignoreWhen(fn (string $path, $value) => str_starts_with($path, '_'))
3 ->make();

The callback runs for every value under the root, so keep it cheap. A key with a dot in its name is passed escaped: a\.b.

Filters and lists

A list that loses elements stays a list and is re-indexed. A map that loses every key stays an empty map, which differs from [].

Next: Paths & Wildcards: reach nested values, and apply rules to one field only.

#Paths & Wildcards

Read this when you need to target nested values, like every item's price, or apply a rule to one field only. It builds on Filtering.

#Dot Paths

Join keys with dots to reach inside nested arrays. List indexes are keys too:

Path Matches
user.email $value['user']['email']
items.0.price the price of the first item
1Fingerprint::for($order)->ignore(['customer.updated_at', 'items.0.note'])->make();

only() with a nested path also keeps the arrays leading to it:

1Fingerprint::for([
2 'user' => ['email' => '[email protected]', 'name' => 'Ahmed'],
3 'meta' => ['seen' => 3],
4])->only(['user.email'])->result()->normalized();
5// ['user' => ['email' => '[email protected]']]

#Wildcards

* matches any one key or list index, so a single path can reach many values:

Path Matches
items.*.price the price of every item
*.id the id of every top-level entry
1Fingerprint::for($order)->ignore('items.*.cached_label')->make();

A path that matches nothing is simply a no-op.

#Scoping Rules With at()

So far every path went into a filter. The value rules you'll meet next, such as trim(), lower() or unordered(), apply to every value until you call at(). After at(), they apply only to the values at that path, until the next at():

1Fingerprint::for($user)
2 ->trim() // every string
3 ->at('email')->lower() // only email
4 ->at('roles')->unordered() // only roles
5 ->make();

Rules after one at() combine: ->at('email')->trim()->lower() does both. Filters such as ignore() and only() already take their own paths, so at() never scopes them.

Declare global rules first, then the at() ones. How Rules Combine explains what happens when rules overlap.

#Special Characters in Keys

You only need this when a key itself contains a dot, a * or a backslash. Escape it with a backslash:

Path Matches
config.app\.name the key app.name inside config
tags.\* the key that is literally *
path\\to the key path\to

In a double-quoted PHP string, write the backslash twice: "config.app\\.name".

Paths are validated strictly and never trimmed: an empty path, or one with an empty segment (a..b, .a, a.), throws a ConfigurationException when you declare it. The syntax is the same as in pharaonic/php-dot-array.

Next: Normalization: make equal-looking values match.

#Normalization

Read this when equal-looking data should match: extra spaces, upper and lower case, rounding, numbers stored as strings, enum cases stored as values, or dates in different timezones.

Value rules turn equivalent values into the same value before hashing. Each applies to every value of its type, or only at a path after at(). The page goes from the most common rules to the most flexible one, transform().

#Strings

Rule Effect
trim() Removes ASCII whitespace (space, tab, line feed, carriage return, vertical tab, form feed) from both ends
normalizeWhitespace() Turns every run of ASCII whitespace into one space, with none at either end
lower() / upper() Changes case with mbstring, in UTF-8
unicode($form) Applies a Unicode normalization form: NFC, NFD, NFKC or NFKD
1Fingerprint::for([' Ahmed ', "a \t\n b", 'ÉCOLE'])
2 ->trim()->normalizeWhitespace()->lower()
3 ->result()->normalized();
4// ['ahmed', 'a b', 'école']

These rules never depend on the locale. They need valid UTF-8 and throw an UnsupportedValueException for anything else instead of silently replacing bytes. unicode() needs ext-intl or symfony/polyfill-intl-normalizer, and throws a MissingExtensionException without either.

#Numbers

precision($digits) rounds floats to 0 to 53 decimals, from their exact binary value, the same way on every PHP version:

1Fingerprint::for(0.1 + 0.2)->precision(2)->make() === Fingerprint::for(0.3)->precision(2)->make(); // true
2 
3Fingerprint::for(0.285)->precision(2)->result()->normalized(); // 0.28

0.285 is stored as 0.28499999…, so it becomes 0.28, not 0.29. Integers, INF and -INF are left as they are.

normalizeNumericStrings() turns plain decimal strings into numbers. Integers that fit become int, everything else float:

1Fingerprint::for(['1', '1.50', '1e3', ' 1', '01', '+1'])
2 ->normalizeNumericStrings()
3 ->result()->normalized();
4// [1, 1.5, 1000.0, ' 1', '01', '+1']

Only an optional minus sign, digits without leading zeros, an optional fraction and an optional exponent qualify: ' 1', '+1', '01', '.5' and '1.' stay strings.

#Booleans

normalizeBooleans() turns true, 1, '1' and 'true' into true, and false, 0, '0' and 'false' into false. Anything else is left alone:

1Fingerprint::for([1, '1', 'true', 0, 'false', 'yes'])->normalizeBooleans()->result()->normalized();
2// [true, true, true, false, false, 'yes']

#Enums

An enum case is fingerprinted as its enum class, its case name and, for a backed enum, its value. Two rules use a plain value instead, so a case can match data that stores the value or the name. The examples use these enums:

1enum Status: string { case Pending = 'pending'; case Paid = 'paid'; }
2enum Suit { case Hearts; case Spades; }
Rule Status::Paid becomes
enumsByValue() 'paid', its backed value (an int for int-backed enums)
enumsByName() 'Paid', its case name
1Fingerprint::for(['status' => Status::Paid])->enumsByValue()->make()
2 === Fingerprint::make(['status' => 'paid']); // true

Like every value rule, they can be scoped with at(), and the last one declared for a path wins:

1Fingerprint::for(['status' => Status::Paid, 'suit' => Suit::Hearts])
2 ->at('status')->enumsByValue()
3 ->at('suit')->enumsByName()
4 ->result()->normalized();
5// ['status' => 'paid', 'suit' => 'Hearts']

The string and number rules then apply to the result, so ->enumsByName()->lower() gives 'paid'. A case of an enum without backed values has nothing for enumsByValue() to use:

1Fingerprint::for(['suit' => Suit::Hearts])->enumsByValue()->make();
2// UnsupportedValueException: The enum case Suit::Hearts has no backed value for enumsByValue() at path "suit".

A normalize() callback for the enum class takes precedence over both rules, as for any object (Objects).

#Dates

DateTimeInterface values (DateTime, DateTimeImmutable, Carbon…) are compared by their UTC instant, with microseconds. The same moment in two timezones gives one fingerprint:

1$cairo = new DateTimeImmutable('2026-10-12 10:00:00', new DateTimeZone('Africa/Cairo'));
2$utc = new DateTime('2026-10-12 07:00:00', new DateTimeZone('UTC'));
3 
4Fingerprint::make($cairo) === Fingerprint::make($utc); // true
Rule Effect
datePrecision($unit) Truncates to microsecond (the default), second, minute, hour or day, in UTC
keepTimezone() Compares the local time and timezone name instead of the instant
1Fingerprint::for($cairo)->datePrecision('day')->result()->normalized(); // "2026-10-12T00:00:00.000000Z"
2Fingerprint::for($cairo)->keepTimezone()->result()->normalized(); // "2026-10-12T10:00:00.000000 Africa/Cairo"

The original date objects are never changed.

Named timezones

With keepTimezone(), or when you build dates from local times in named zones, results depend on the timezone database of the PHP build, which changes when countries change their rules. Fixed offsets such as +02:00 never change.

#transform()

transform() replaces a value with your callback's result before the built-in rules run. The callback receives the value and its escaped path. The result is normalized like any other value, so it may be an array:

1Fingerprint::for(['phone' => '+20 100 000 0000'])
2 ->at('phone')->transform(fn (string $phone) => preg_replace('/\D/', '', $phone))
3 ->result()->normalized();
4// ['phone' => '201000000000']

Next: Lists: ignore the order of a list, or its duplicates.

#Lists

Read this when the order of a list, such as tags, shouldn't change the fingerprint, or when duplicates should be ignored.

Lists keep their order by default. List rules change that for every list, or only at a path after at(). They have no effect on maps and scalars, so a global unordered() safely reaches every list.

#unordered()

Lists that hold the same elements, in any order, give the same fingerprint:

1$a = Fingerprint::for(['tags' => ['b', 'a']])->at('tags')->unordered()->make();
2$b = Fingerprint::for(['tags' => ['a', 'b']])->at('tags')->unordered()->make();
3 
4$a === $b; // true

Elements are ordered by their canonical bytes. Duplicates still count: ['a', 'a', 'b'] and ['a', 'b'] differ.

#unique()

unique() removes repeated elements and keeps the first occurrence. Add it to unordered() when duplicates don't matter either:

1Fingerprint::for(['b', 'a', 'b'])->unordered()->unique()->result()->normalized(); // ['a', 'b']

Elements are compared strictly, so 1 and '1' are both kept:

1Fingerprint::for([1, '1', 1, 2])->unique()->result()->normalized(); // [1, '1', 2]

#sortBy()

unordered() compares whole elements. For a list of records, such as order items, it's often clearer to order them by one field, several fields, or fields with a direction. Fields may be dot paths inside each element, without wildcards:

1$builder->at('items')->sortBy('id');
2$builder->at('items')->sortBy(['category', 'id']);
3$builder->at('items')->sortBy(['category' => 'asc', 'priority' => 'desc']);
4$builder->at('items')->sortBy('meta.position');

Two orders with the same items, listed in a different order, then match:

1$a = ['items' => [['id' => 2, 'qty' => 1], ['id' => 1, 'qty' => 3]]];
2$b = ['items' => [['id' => 1, 'qty' => 3], ['id' => 2, 'qty' => 1]]];
3 
4Fingerprint::make($a) === Fingerprint::make($b); // false
5 
6$byId = Fingerprint::for(null)->at('items')->sortBy('id');
7$byId->for($a)->make() === $byId->for($b)->make(); // true

Ties keep their input order. Values of different types are ordered by type: a missing field, then null, booleans, numbers, strings, then arrays.

#uniqueBy()

uniqueBy($field) removes records that repeat a field's value, keeping the first:

1Fingerprint::for([['id' => 1, 'v' => 'a'], ['id' => 1, 'v' => 'b']])
2 ->uniqueBy('id')
3 ->result()->normalized();
4// [['id' => 1, 'v' => 'a']]

Lists are ordered first, then de-duplicated, so "first" means first in the final order. Elements without the field share one value for uniqueBy().

#sortUsing()

When no field order fits, order elements with your own comparator, like usort(). It receives two elements as plain PHP values and must return an integer:

1$builder->at('items')->sortUsing(fn (array $a, array $b) => $a['rank'] <=> $b['rank']);

#Which Rule Wins

unordered(), sortBy() and sortUsing() replace each other for a path, and so do unique() and uniqueBy(). The last one declared wins.

That's the end of The Basics. You can now control which values count and when two values are equal.

Next: Going Further starts with Hashing & HMAC: secrets, other algorithms and output formats.

#Hashing & HMAC

The default (SHA-256, hex) fits most uses. Read this when the fingerprint must be secret, shorter, or in another format. The options go from the simplest (another algorithm or format) to the one that needs care (a secret).

Every builder (FingerprintBuilder, SignatureBuilder and DigestBuilder) shares the same hashing options.

#Algorithms

SHA-256 is the default. algorithm() accepts any algorithm your PHP's hash() supports:

1Fingerprint::for($data)->algorithm('sha512')->make();
2Fingerprint::for($data)->algorithm('crc32b')->make(); // "a543c5c7" for ['a' => 1]

The fast xxh32, xxh64, xxh3, xxh128, murmur3a, murmur3c and murmur3f algorithms are available too:

1Fingerprint::for(['a' => 1])->algorithm('xxh128')->make(); // "9f013320af9f365ab723e99883d27b08"

An algorithm PHP doesn't have throws a ConfigurationException instead of falling back:

1Fingerprint::for($data)->algorithm('sha257');
2// ConfigurationException: The hash algorithm "sha257" is not available on PHP 8.2.30.
Non-cryptographic algorithms

xxh*, murmur3*, crc32b, fnv1a64, md5 and similar algorithms are fine for change detection and cache keys, but anyone can produce a collision. Use SHA-256 or stronger, with hmac() (below), when the fingerprint protects anything. xxh*, murmur3* and the checksums can't be used with hmac() at all.

#Output Encodings

Method Output for ['a' => 1]
hex() (default) 8007ef36e8ee76e080e880d450b79d39cd4a544ae3962f08f40d96211ae3946e
binary() the 32 raw bytes
base64() gAfvNujuduCA6IDUULedOc1KVErjli8I9A2WIRrjlG4=
base64Url() gAfvNujuduCA6IDUULedOc1KVErjli8I9A2WIRrjlG4 (- and _, no padding)

#Namespaces

namespace() keeps fingerprints of the same data used for different purposes apart. An "invoice" fingerprint never matches an "order" fingerprint of the same data, nor one without a namespace:

1$invoice = Fingerprint::for(['id' => 7])->namespace('invoice')->make();
2$order = Fingerprint::for(['id' => 7])->namespace('order')->make();
3 
4$invoice === $order; // false

A namespace is not a secret: anyone who knows it can compute the same fingerprint. That's what HMAC is for.

#HMAC

hmac() signs with a secret instead of computing a plain hash, so nobody without the secret can produce a matching fingerprint:

1Fingerprint::for($data)->hmac($secret)->make();
2Fingerprint::for($data)->algorithm('sha512')->hmac($secret)->make();

An empty secret throws a ConfigurationException. An algorithm that cannot do HMAC, such as crc32b, fnv1a64 or joaat, throws a ConfigurationException when you hash, not when you call hmac().

#Verifying

verify() recomputes the fingerprint with the same algorithm, secret, namespace and encoding, and compares it with hash_equals() in constant time. Hex is compared case-insensitively, other encodings exactly:

1$builder = Fingerprint::for($data)->namespace('webhook')->hmac($secret);
2 
3$builder->verify($received); // true or false

Fingerprint::verify($value, $expected) is the shortcut for a plain SHA-256 hex fingerprint.

#Keeping Secrets Safe

  • var_dump() and print_r() of a builder show only the algorithm, encoding, whether HMAC is on, and the namespace (or the signature fields).
  • serialize() of a builder throws a LogicException, so a builder never ends up in a cache or a queue with its secret.
  • No exception message or trace from the package contains the secret, and result() never holds it. hmac() marks the secret with #[\SensitiveParameter], so even a trace with arguments shows a SensitiveParameterValue instead of it.
  • PHP itself can still read private properties through var_export(), (array) casts and Reflection, so don't pass builders to code that dumps them that way.

#What a Fingerprint Is Not

  • A fingerprint is not encryption: it can't be reversed, but anyone can fingerprint guessed data and compare. Use hmac() when the data is guessable and the fingerprint must not be forgeable.
  • A namespace is not a secret: it only keeps fingerprints for different purposes apart.
  • There is no salt option. Use namespace() to separate purposes and hmac() for secrecy.

Next: Files & Streams: the same hashing options for raw bytes.

#Files & Streams

Read this to fingerprint a file or a stream's raw bytes, such as an upload or a download. It uses the hashing options from the previous page.

Fingerprint::file() and Fingerprint::stream() hash raw bytes exactly as they are, without canonicalization. They read in chunks, so a large file never loads into memory. They return a DigestBuilder with the same algorithm(), hmac(), encoding and verify() options:

1use Pharaonic\Fingerprint\Fingerprint;
2 
3Fingerprint::file('/backups/db.sql.gz')->make(); // the same as hash_file('sha256', ...)
4Fingerprint::file($path)->algorithm('sha512')->base64()->make();
5Fingerprint::file($path)->hmac($secret)->verify($expected);

#Streams

A stream is read from its current position to its end. It is never rewound or closed, so a second make() on the same stream only sees what is left:

1$stream = fopen('php://temp', 'r+');
2fwrite($stream, $body);
3rewind($stream);
4 
5$fingerprint = Fingerprint::stream($stream)->make();

#Errors

A missing file, a directory, an unreadable file, a closed or non-stream resource, or a stream opened for writing only throws an UnreadableResourceException:

1Fingerprint::file('/missing')->make();
2// UnreadableResourceException: The file "/missing" does not exist or cannot be read.
Trusted paths only

file() reads whatever path PHP accepts, stream wrappers included. Never pass a path that comes from user input without validating it first.

Next: Objects: fingerprint data that holds models, value objects or collections.

#Objects

Read this when your data holds objects other than plain arrays: models, value objects, collections. stdClass and dates already work, as How Matching Works showed.

Objects are converted into plain data, in this order:

  1. closures and generators are rejected;
  2. a normalize() callback for the object's class or one of its types;
  3. DateTimeInterface stays a date (see Dates);
  4. an enum case stays an enum case, even when its enum implements JsonSerializable (see Enums);
  5. JsonSerializable uses what jsonSerialize() returns;
  6. Traversable (ArrayObject, ArrayIterator, IteratorAggregate…) is iterated;
  7. stdClass becomes a map, even when empty or numerically keyed;
  8. anything else throws an UnsupportedValueException.

Private and protected properties are never read. What a conversion returns is converted again, so a JsonSerializable may return another one.

A value object that already implements JsonSerializable needs nothing more:

1final class Money implements JsonSerializable
2{
3 public function __construct(private int $amount, private string $currency) {}
4 
5 public function jsonSerialize(): array
6 {
7 return ['amount' => $this->amount, 'currency' => $this->currency];
8 }
9}
10 
11Fingerprint::for(['total' => new Money(1050, 'EGP')])->result()->normalized();
12// ['total' => ['amount' => 1050, 'currency' => 'EGP']]

Use a custom normalizer for classes you can't change, or when you want a different shape than jsonSerialize().

#Custom Normalizers

normalize() converts objects of a class, or implementing an interface, with your callback. It applies everywhere, whatever at() scope is active:

1use App\Models\Money;
2 
3Fingerprint::for($invoice)
4 ->normalize(Money::class, fn (Money $money) => [$money->amount(), $money->currency()])
5 ->make();

An exact class match wins. Otherwise the first matching class or interface wins, in declaration order. Stringable objects are rejected by default; opt them in with:

1->normalize(Stringable::class, fn (Stringable $value) => (string) $value)

#Traversable Objects

Iterated keys must be integers or strings, and must not repeat, or an UnsupportedValueException is thrown. Generators are always rejected, because iterating them runs code and can only be done once.

#Circular References

An object that contains itself throws a CircularReferenceException that names the path. The same object used twice in different places is fine:

1$node = new ArrayObject();
2$node['self'] = $node;
3 
4Fingerprint::make($node);
5// CircularReferenceException: An object of class ArrayObject contains itself at path "self".

#Maximum Depth

Values may nest 512 levels deep by default, like json_decode(). Deeper values throw a MaximumDepthExceededException instead of exhausting the stack; arrays that reference themselves hit this limit too. Change it with maxDepth():

1Fingerprint::for($tree)->maxDepth(64)->make();

Objects count towards the depth. maxDepth() must be at least 1.

Next: How Rules Combine: what happens when several rules reach the same value.

#How Rules Combine

Read this when several rules reach the same value and the result isn't what you expected. It brings together the rules from the previous pages, so read those first.

#The Last Rule of a Kind Wins

Rules of different kinds combine: ->at('email')->trim()->lower() does both. For the same kind of rule, the last one declared for a path wins, so a path rule overrides a global rule of the same kind:

1Fingerprint::for($user)
2 ->lower() // every string
3 ->at('code')->upper() // code is upper case, not lower case
4 ->make();

These rules are one kind each:

  • lower() and upper();
  • unordered(), sortBy() and sortUsing();
  • unique() and uniqueBy().

A global rule also applies to the root value itself:

1Fingerprint::for(' Ahmed ')->trim()->result()->normalized(); // "Ahmed"

#Rules Run in a Fixed Order

Rules always run in the same order, whatever order you declare them in, so ->lower()->trim() and ->trim()->lower() are the same. For each value:

  1. objects become plain data (Objects);
  2. ignoreWhen() callbacks, which see the value before any value rule;
  3. transform(), whose result is converted again;
  4. enumsByName() or enumsByValue();
  5. string rules: trim(), normalizeWhitespace(), unicode(), then lower() or upper();
  6. normalizeNumericStrings(), then precision();
  7. normalizeBooleans();
  8. date rules: datePrecision() and keepTimezone();
  9. the value's children;
  10. list rules: ordering, then de-duplication.

ignoreNulls(), ignoreEmptyStrings() and ignoreEmptyArrays() check each value after its rules apply.

Because a list's rules run after its children (9 and 10 above), you can clean each element and then order the list:

1Fingerprint::for($contact)
2 ->at('phones.*')->transform(fn (string $phone) => preg_replace('/\D/', '', $phone))
3 ->at('phones')->unordered()->unique()
4 ->make();

#Paths Refer to the Input

Paths always refer to the input keys. When a filter removes an element, the next one keeps its input index for every other rule, even though the list is re-indexed in the result:

1Fingerprint::for(['items' => ['a', ' b ', ' c ']])
2 ->ignore('items.0')
3 ->at('items.1')->trim() // still ' b ', the second input element
4 ->result()->normalized();
5// ['items' => ['b', ' c ']]

That's the end of Going Further.

Next: Ordered Signatures: verify payment gateway callbacks and webhooks.

#Ordered Signatures

Payment gateways, banks and webhook providers often sign a callback by joining selected fields in a fixed order, then hashing or HMAC-signing the result. Fingerprint::signature() reproduces that message exactly.

This page uses at() from Paths, the rules from Normalization, and algorithm() and hmac() from Hashing & HMAC. Nothing here is specific to one provider: you describe the provider's format, and the package builds it. Signatures are a separate pipeline from fingerprints, though: nothing is sorted, and the order of fields() is the order of the message.

#Build, Sign, Verify

Build the message. List the fields in the provider's order. message() shows the exact string that will be signed:

1use Pharaonic\Fingerprint\Fingerprint;
2 
3Fingerprint::signature(['amount_cents' => 1050, 'currency' => 'EGP', 'success' => true])
4 ->fields(['amount_cents', 'currency', 'success'])
5 ->message();
6// "1050EGPtrue"

Sign it. Add the provider's algorithm and your secret, then call make():

1$signature = Fingerprint::signature($payload)
2 ->fields(['amount_cents', 'currency', 'success'])
3 ->algorithm('sha512')
4 ->hmac($secret);
5 
6$signature->make(); // the signature

Verify what you received. verify() compares in constant time:

1$signature->verify($received); // true or false

#Selecting Fields

fields() takes dot paths, without wildcards, in exactly the protocol's order. Calling it again replaces the list. The payload may be an array, an ArrayAccess, a JsonSerializable, or an object with public properties, such as the stdClass that json_decode() returns. Private and protected properties are never read.

#Shaping the Message

Method Effect Default
valuesOnly() value1 . separator . value2 … yes
keyValue() key1 . keyValueSeparator . value1 . separator . key2 …
separator($string) Between fields ''
keyValueSeparator($string) Between a key and its value '='
1Fingerprint::signature(['order' => ['amount' => 1050], 'currency' => 'EGP', 'paid' => true])
2 ->fields(['amount' => 'order.amount', 'currency', 'paid'])
3 ->keyValue()->separator('&')
4 ->message();
5// "amount=1050&currency=EGP&paid=true"

A string key in fields(), like 'amount' => 'order.amount' above, renames the field in keyValue() output.

#How Values Become Strings

Value String
string as it is
int decimal: 1050
bool true or false
float needs precision() on its field: 10.50
backed enum case its backed value: Status::Paid gives paid
enum case without a backed value needs enumsByName() or a transform()
array, object, date needs a transform() that returns one of the types above
1Fingerprint::signature(['total' => 10.5])
2 ->fields(['total'])
3 ->at('total')->precision(2)
4 ->message();
5// "10.50"

A float without precision() throws an UnsupportedValueException, because PHP turns floats into text differently depending on the precision ini setting. So does an enum case without a backed value, because only its case name could stand for it, and a protocol should not depend on PHP case names by accident:

1Fingerprint::signature(['id' => 7, 'status' => Status::Paid])->fields(['id', 'status'])->separator('|')->message();
2// "7|paid"
3 
4Fingerprint::signature(['suit' => Suit::Hearts])->fields(['suit'])->message();
5// UnsupportedValueException: The field "suit" holds Suit; use enumsByName() or transform().
6 
7Fingerprint::signature(['suit' => Suit::Hearts])->fields(['suit'])->enumsByName()->message();
8// "Hearts"

Use at() to apply trim(), normalizeWhitespace(), lower(), upper(), unicode(), precision(), enumsByName(), enumsByValue() and transform() to one field.

#Missing and Null Fields

By default, a missing field throws a MissingFieldException and a null field throws an UnsupportedValueException, so a protocol change never goes unnoticed. Choose otherwise with onMissing() and onNull(), independently:

Strategy The field becomes
'throw' an exception (the default)
'empty' an empty string
'null' the string null
'skip' nothing: the field is left out, with its separator
a closure the string it returns for the field path, or nothing when it returns null

The difference shows in the message. Here the field b is missing:

1$signature = Fingerprint::signature(['a' => '1', 'c' => '3'])
2 ->fields(['a', 'b', 'c'])
3 ->separator('|');
4 
5$signature->onMissing('empty')->message(); // "1||3"
6$signature->onMissing('skip')->message(); // "1|3"
7$signature->onMissing('null')->message(); // "1|null|3"

A closure decides per field:

1->onNull(fn (string $field) => $field === 'note' ? '' : null)

A path through a null or a scalar counts as missing. Exception messages name the field path, never its value.

#When the Signature Doesn't Match

Print the message and compare it with the provider's documentation:

1Fingerprint::signature($payload)->fields($fields)->message();

Check the field order, the separators, how booleans and amounts are written (true or 1, 1050 or 10.50), and whether missing fields should be empty or skipped. Then check the secret and the algorithm.

Next: Signature Examples: complete examples for a webhook, Paymob and Kashier.

#Signature Examples

These examples use the methods from Ordered Signatures. The package has no code for any particular provider: each example only describes a provider's format with fields(), separator(), algorithm() and hmac(). They go from the simplest to the most involved.

#1. Your Own Webhooks

When you control both sides, the sender and the receiver build the same ordered message from the same fields:

src/Webhooks/Sender.php
1use Pharaonic\Fingerprint\Fingerprint;
2 
3$signature = Fingerprint::signature($event)
4 ->fields(['id', 'type', 'amount' => 'data.amount', 'currency' => 'data.currency'])
5 ->keyValue()->separator('&')
6 ->hmac($sharedSecret)
7 ->make();
8 
9$http->post($url, $event, ['X-Signature' => $signature]);
src/Webhooks/Receiver.php
1use Pharaonic\Fingerprint\Fingerprint;
2 
3$event = json_decode($body, true);
4 
5$valid = Fingerprint::signature($event)
6 ->fields(['id', 'type', 'amount' => 'data.amount', 'currency' => 'data.currency'])
7 ->keyValue()->separator('&')
8 ->hmac($sharedSecret)
9 ->verify($headers['X-Signature'] ?? '');

#2. Paymob Transaction Callback

When a payment finishes, Paymob sends your server a transaction processed callback: a POST request with a JSON body and the signature in the URL (?hmac=...). Anyone can call your callback URL, so check that signature before you trust the request.

What Paymob sends. The body looks like this (shortened):

POST /paymob/callback?hmac=3f9a...e1
1{
2 "type": "TRANSACTION",
3 "obj": {
4 "id": 192036465,
5 "amount_cents": 100000,
6 "currency": "EGP",
7 "success": true,
8 "pending": false,
9 "created_at": "2024-06-13T11:33:44.592345",
10 "order": { "id": 217503754 },
11 "source_data": { "type": "card", "pan": "2346", "sub_type": "MasterCard" }
12 }
13}

How Paymob signs it. Paymob takes 20 fields of obj, always in the same (alphabetical) order, writes each value as text (true/false for booleans), joins them with nothing in between, and signs the result with HMAC-SHA512 and your HMAC secret from the Paymob dashboard.

Do the same and compare. List the fields in Paymob's order, and let the package build the text and compare the signatures:

public/paymob-callback.php
1use Pharaonic\Fingerprint\Fingerprint;
2 
3$transaction = json_decode(file_get_contents('php://input'))->obj;
4 
5$valid = Fingerprint::signature($transaction)
6 ->fields([
7 'amount_cents', 'created_at', 'currency', 'error_occured', 'has_parent_transaction', 'id',
8 'integration_id', 'is_3d_secure', 'is_auth', 'is_capture', 'is_refunded', 'is_standalone_payment',
9 'is_voided', 'order.id', 'owner', 'pending', 'source_data.pan', 'source_data.sub_type',
10 'source_data.type', 'success',
11 ])
12 ->algorithm('sha512')
13 ->hmac(getenv('PAYMOB_HMAC_SECRET'))
14 ->verify($_GET['hmac'] ?? '');
15 
16if (! $valid) {
17 http_response_code(403);
18 exit;
19}
20 
21// The callback really comes from Paymob: update the order here.
app/Http/Controllers/PaymobCallbackController.php
1namespace App\Http\Controllers;
2 
3use Illuminate\Http\Request;
4use Pharaonic\Fingerprint\Fingerprint;
5 
6class PaymobCallbackController extends Controller
7{
8 private const FIELDS = [
9 'amount_cents', 'created_at', 'currency', 'error_occured', 'has_parent_transaction', 'id',
10 'integration_id', 'is_3d_secure', 'is_auth', 'is_capture', 'is_refunded', 'is_standalone_payment',
11 'is_voided', 'order.id', 'owner', 'pending', 'source_data.pan', 'source_data.sub_type',
12 'source_data.type', 'success',
13 ];
14 
15 public function __invoke(Request $request)
16 {
17 $transaction = json_decode($request->getContent())->obj;
18 
19 $valid = Fingerprint::signature($transaction)
20 ->fields(self::FIELDS)
21 ->algorithm('sha512')
22 ->hmac(config('services.paymob.hmac'))
23 ->verify((string) $request->query('hmac'));
24 
25 abort_unless($valid, 403);
26 
27 // The callback really comes from Paymob: update the order here.
28 
29 return response()->noContent();
30 }
31}

Trust the data, not just the signature. A valid signature proves Paymob sent the callback. Still check that success is true, and that amount_cents and currency match the order in your database before marking it paid.

When it doesn't match, print the text that is signed and compare it with what Paymob describes:

1Fingerprint::signature($transaction)->fields($fields)->message();
2// "1000002024-06-13T11:33:44.592345EGPfalsefalse192036465..."

The usual causes are the wrong HMAC secret, a field missing from the list or in the wrong place, or passing the whole body instead of its obj part.

Other Paymob callbacks

Paymob also redirects the customer back to your site with the transaction in the query string. That callback lists its fields differently, and PHP turns dots in query names into underscores (source_data.pan arrives as source_data_pan), so check Paymob's documentation for its field list. The package has no Paymob-specific code: the same steps work for any gateway that signs ordered fields.

#3. Kashier Payment Hash

Kashier's payment page expects a hash of the order, so here you make a signature instead of verifying one. Kashier signs the path /?payment= followed by the merchant ID, the order ID, the amount and the currency, joined with dots, with HMAC-SHA256 and your payment API key:

src/Payments/KashierHash.php
1use Pharaonic\Fingerprint\Fingerprint;
2 
3$hash = Fingerprint::signature([
4 'merchantId' => 'mid-0-1',
5 'orderId' => '99',
6 'amount' => '20',
7 'currency' => 'EGP',
8 ])
9 ->fields(['merchantId', 'orderId', 'amount', 'currency'])
10 ->separator('.')
11 ->at('merchantId')->transform(fn (string $mid) => '/?payment='.$mid)
12 ->hmac($apiKey);
13 
14$hash->message(); // "/?payment=mid-0-1.99.20.EGP"
15$hash->make(); // "606a8a1307d64caf4e2e9bb724738f115a8972c27eccb2a8acd9194c357e4bec" with the key "11111"

The prefix is not a field, so transform() adds it to the first one. The values above are Kashier's own test vector: check your setup against it before going live. Generate the hash on your server, never in the browser, and check Kashier's documentation for the optional fields some payment types add.

#Your Provider

The same steps work for any provider that signs ordered fields. From its documentation, note:

  1. the fields, in order, and whether it joins values only or key=value pairs (Shaping the Message);
  2. the separator, if any;
  3. how it writes booleans, amounts and dates (How Values Become Strings);
  4. the algorithm, and whether it uses HMAC with a secret.

Build the message with message() first, compare it with an example from the provider, then add hmac() and verify().

Next: the Reference pages. Start with Debugging when a result surprises you, or browse the Examples.

#Debugging

result() returns a FingerprintResult with the fingerprint and everything needed to understand it:

1$result = Fingerprint::for(['b' => 1, 'a' => [true, null, 'x', 1.5]])->result();
2 
3$result->hash(); // the same string make() returns
4$result->algorithm(); // "sha256"
5$result->encoding(); // "hex"
6$result->hmac(); // false
7$result->namespace(); // null
8$result->version(); // "v1"
9$result->canonical(); // "fp1;nm2{s1:al4[b1ns1:xd3ff8000000000000]s1:bi1;}"
10$result->normalized(); // ['b' => 1, 'a' => [true, null, 'x', 1.5]]
11$result->toArray(); // all of the above, as an array
  • canonical() holds the exact bytes that were hashed. Compare it for two values to see why their fingerprints differ.
  • normalized() is the value after filters and value rules, as plain PHP data. Map keys keep their input order there; canonical() shows the canonical order. Dates appear as ISO 8601 strings: "2026-10-12T07:00:00.000000Z", or the local time and timezone name with keepTimezone().

A result never holds the HMAC secret, so it is safe to log.

#Reading the Canonical Bytes

Token Meaning
fp1; Format v1 header
n / s7:invoice No namespace / the namespace invoice
n null
b0 / b1 false / true
i42; the integer 42
d3ff8000000000000 a float, as its 8 IEEE-754 bytes in hexadecimal (here 1.5)
s5:hello a string, with its length in bytes
l2[…] a list of 2 elements
m2{…} a map of 2 entries, keys in canonical order
t2026-10-12T07:00:00.000000Z a date, as its UTC instant
z39:2026-10-12T10:00:00.000000 Africa/Cairo a date with keepTimezone()
es6:Statuss4:Paids4:paid an enum case: its class, its case name, then its backed value (n when it has none)

For a signature, message() plays the same role: it returns the exact string that is hashed or signed.

#Guarantees & Limitations

#Determinism

The same value with the same configuration gives the same fingerprint:

  • on every run, machine and operating system;
  • whatever the locale (setlocale()) and the precision / serialize_precision ini settings;
  • on every supported PHP version: canonical format v1 never changes, and the test suite checks it against hand-written golden vectors.

The canonical form starts with a version header (fp1;). If the format ever has to change, it gets a new version, so old and new fingerprints can never collide by accident.

#Invariants

  • Associative key order never affects a fingerprint, at any depth. This can't be turned off.
  • Lists keep their order unless you choose a list rule.
  • Types are preserved unless you choose a normalization rule.
  • null differs from a missing key unless you use ignoreNulls().
  • The order of only() paths never matters. The order of signature fields() always does.

#Limitations

  • Hash algorithms: only the algorithms of your PHP build are available.
  • Unicode tables: lower(), upper() and unicode() use the Unicode tables of mbstring and ICU. Characters added in recent Unicode versions can be handled differently by older PHP or ICU builds, so avoid relying on them when fingerprints must match across servers.
  • Named timezones: dates built from named zones depend on each PHP build's timezone database. See Dates.
  • Numeric string keys: PHP casts the key '1' to 1 before the package sees it, so the two can't be told apart.
  • Arbitrary objects are rejected instead of read through their public properties. Convert them with normalize().
  • Cross-language use: the canonical form is PHP-specific (it keeps 1 and 1.0 apart, unlike JSON). Use signatures when another system must compute the same value.

#API Reference

All classes live in the Pharaonic\Fingerprint namespace. Builders are immutable: every method that configures one returns a new builder.

#Fingerprint

Method Description Returns
make(mixed $value) SHA-256 hex fingerprint of a value string
verify(mixed $value, string $expected) Constant-time check against a SHA-256 hex fingerprint bool
for(mixed $value) Starts a configurable fingerprint FingerprintBuilder
signature(array|object $payload) Starts an ordered signature SignatureBuilder
file(string $path) Starts a fingerprint of a file's raw bytes DigestBuilder
stream(resource $stream) Starts a fingerprint of a stream's raw bytes DigestBuilder

#FingerprintBuilder

Method Description
for(mixed $value) The same configuration bound to another value
ignore(string|array $paths) Leaves out the values at these paths
only(array $paths) Keeps only the values at these paths
ignoreWhen(callable $callback) Leaves out values for which fn (string $path, mixed $value): bool returns true
ignoreNulls() Leaves out values that end up null
ignoreEmptyStrings() Leaves out values that end up ''
ignoreEmptyArrays() Leaves out arrays that end up empty
normalize(string $class, callable $normalizer) Converts objects of a class or interface
at(string $path) Scopes the following value rules to a path
transform(callable $callback) Replaces values with fn (mixed $value, string $path): mixed
enumsByName() / enumsByValue() Uses an enum case's name or backed value instead of the case
trim() Trims ASCII whitespace from strings
normalizeWhitespace() Collapses ASCII whitespace runs in strings
lower() / upper() Changes the case of strings (UTF-8)
unicode(string $form) Applies NFC, NFD, NFKC or NFKD
precision(int $digits) Rounds floats to 0 to 53 decimals
normalizeNumericStrings() Turns plain decimal strings into numbers
normalizeBooleans() Turns 1, '1', 'true', 0, '0', 'false' into booleans
datePrecision(string $unit) Truncates dates to microsecond, second, minute, hour or day
keepTimezone() Compares dates by local time and timezone
unordered() Ignores list order
sortBy(string|array $fields) Orders list elements by fields
sortUsing(callable $comparator) Orders list elements with a comparator
unique() Removes repeated list elements
uniqueBy(string $field) Removes list elements that repeat a field
maxDepth(int $depth) Changes the nesting limit (512)
namespace(string $name) Separates fingerprints by purpose
algorithm(string $algorithm) Changes the hash algorithm (sha256)
hmac(string $secret) Signs with an HMAC secret
hex() / binary() / base64() / base64Url() Chooses the output encoding (hex)
make(): string The fingerprint
verify(string $expected): bool Constant-time check
result(): FingerprintResult The fingerprint with its canonical bytes and normalized value

#SignatureBuilder

Method Description
fields(array $fields) The fields to sign, in order; string keys rename them
valuesOnly() / keyValue() Joins values only (default) or key-value pairs
separator(string $separator) Between fields ('')
keyValueSeparator(string $separator) Between a key and its value ('=')
onMissing(string|Closure $strategy) 'throw' (default), 'empty', 'null', 'skip' or a closure
onNull(string|Closure $strategy) The same choices, for null values
at(), transform(), enumsByName(), enumsByValue(), trim(), normalizeWhitespace(), lower(), upper(), unicode(), precision() Value rules per field, as on FingerprintBuilder
algorithm(), hmac(), hex(), binary(), base64(), base64Url() Hashing options
message(): string The exact string that is signed
make(): string The signature
verify(string $expected): bool Constant-time check

#DigestBuilder

Method Description
algorithm(), hmac(), hex(), binary(), base64(), base64Url() Hashing options
make(): string The fingerprint of the bytes
verify(string $expected): bool Constant-time check

#FingerprintResult

Method Returns
hash() string: the fingerprint, as make() returns it
algorithm() string
encoding() string: hex, binary, base64 or base64url
hmac() bool: whether it is an HMAC
namespace() ?string
version() string: the canonical format version, v1
canonical() string: the exact bytes that were hashed
normalized() mixed: the value after filters and rules
toArray() array of all of the above

#Exceptions

Every exception implements Pharaonic\Fingerprint\Exceptions\FingerprintException, so one catch covers them all.

Exception Extends Thrown when
ConfigurationException InvalidArgumentException A path, algorithm, rule, strategy or callback result is invalid
UnsupportedValueException InvalidArgumentException A value can't be fingerprinted or signed
CircularReferenceException UnsupportedValueException An object contains itself
MaximumDepthExceededException UnsupportedValueException A value nests deeper than maxDepth()
MissingFieldException UnexpectedValueException A signature field is missing and onMissing() is 'throw'
MissingExtensionException LogicException unicode() is used without ext-intl or the polyfill
UnreadableResourceException RuntimeException A file or stream can't be read

Builders also throw a LogicException when you try to serialize() them.

#Examples

The examples go from the simplest to the most involved. Each one names the pages it builds on.

#1. Checking Downloaded Files

Uses: Files & Streams.

Compare a download with the checksum the publisher lists, without loading the file into memory:

src/Releases/ChecksumVerifier.php
1use Pharaonic\Fingerprint\Fingerprint;
2 
3if (! Fingerprint::file($archive)->algorithm('sha512')->verify($publishedChecksum)) {
4 throw new RuntimeException('The download is corrupted.');
5}

#2. Skip Unchanged Records in a Sync

Uses: Filtering, Paths, Lists, namespaces.

Store each record's fingerprint and only push records whose fingerprint changed. Timestamps that change on every read are ignored, and tags are compared regardless of order:

src/Sync/ProductSync.php
1use Pharaonic\Fingerprint\Fingerprint;
2 
3$fingerprints = Fingerprint::for(null)
4 ->namespace('product-sync')
5 ->ignore(['updated_at', 'variants.*.stock_checked_at'])
6 ->at('tags')->unordered()
7 ->at('variants')->sortBy('sku');
8 
9foreach ($products as $product) {
10 $fingerprint = $fingerprints->for($product)->make();
11 
12 if ($fingerprint !== $store->lastFingerprint($product['id'])) {
13 $api->push($product);
14 $store->saveFingerprint($product['id'], $fingerprint);
15 }
16}

#3. De-duplicating Imported Contacts

Uses: Filtering, Normalization, Lists, How Rules Combine.

Contacts that differ only in whitespace, case or the order of their phone numbers get the same fingerprint:

src/Import/ContactDeduplicator.php
1use Pharaonic\Fingerprint\Fingerprint;
2 
3$contacts = Fingerprint::for(null)
4 ->only(['name', 'email', 'phones'])
5 ->trim()->normalizeWhitespace()
6 ->at('email')->lower()
7 ->at('phones.*')->transform(fn (string $phone) => preg_replace('/\D/', '', $phone))
8 ->at('phones')->unordered()->unique();
9 
10$unique = [];
11 
12foreach ($rows as $row) {
13 $unique[$contacts->for($row)->make()] ??= $row;
14}

The phones.* rule cleans each number before the phones list is ordered and de-duplicated, because a list's rules run after its children (How Rules Combine).

#4. Idempotency Keys

Uses: How Matching Works, Filtering, Normalization, Hashing & HMAC.

Turn a request body into an idempotency key, so a retried request with the same content, in any key order, is recognised. HMAC keeps the keys unguessable:

src/Http/IdempotencyKey.php
1use Pharaonic\Fingerprint\Fingerprint;
2 
3$key = Fingerprint::for(json_decode($body))
4 ->namespace('payment-request')
5 ->ignore('client_timestamp')
6 ->at('customer.email')->trim()->lower()
7 ->at('amount')->precision(2)
8 ->hmac($appSecret)
9 ->base64Url()
10 ->make();

json_decode() without true returns stdClass objects, which are maps just like associative arrays.

For signatures, see Signature Examples: a webhook, Paymob and Kashier.

#Troubleshooting

#Two values I consider equal get different fingerprints

Compare their canonical bytes to find the difference:

1Fingerprint::for($a)->result()->canonical();
2Fingerprint::for($b)->result()->canonical();

The usual causes are a type difference (7 and '7', 1 and 1.0), a list in a different order, a null against a missing key, or a float such as 0.1 + 0.2. Use normalizeNumericStrings(), unordered(), ignoreNulls() or precision() accordingly.

#The fingerprint changes when I reload the same record

A field changes on every load: a timestamp, a counter, a cache value. Leave it out with ignore(), or select what matters with only().

#A rule doesn't apply where I expect

  • Rules declared after at() only apply at that path, until the next at(). Declare global rules first.
  • For the same kind of rule, the last one wins for a path: a later upper() replaces an earlier lower().
  • Paths refer to input keys: a key with a dot in its name needs \., as in config.app\.name.

See How Rules Combine for the full order.

#UnsupportedValueException for an object

Only stdClass, dates, JsonSerializable and Traversable objects are converted automatically. Add a normalize() callback for the class, or turn the object into an array first. Stringable objects need normalize(Stringable::class, fn ($value) => (string) $value).

#UnsupportedValueException: invalid UTF-8

lower(), upper(), trim(), normalizeWhitespace() and unicode() need valid UTF-8. Scope them with at() so they don't reach binary fields, or convert the encoding first.

#MaximumDepthExceededException

The value nests deeper than 512 levels, or an array references itself. Fix the reference, or raise the limit with maxDepth() if the data really is that deep.

#ConfigurationException: algorithm not available

Your PHP build doesn't have that algorithm. List what's available with hash_algos().

#ConfigurationException: cannot be used for HMAC

Some algorithms, such as crc32b, fnv1a64 and joaat, can't do HMAC. This is reported when you call make() or verify(), not hmac(). Use sha256 or stronger.

#A signature doesn't match the provider's

Print the message and compare it with the provider's documentation:

1Fingerprint::signature($payload)->fields($fields)->message();

Check the field order, the separators, how booleans and amounts are written (true or 1, 1050 or 10.50), and whether missing fields should be empty or skipped. Signatures never sort fields: the order of fields() is the order of the message.

#A float field throws in a signature

Floats need precision() on their field, so the message never depends on PHP's precision ini setting: ->at('total')->precision(2).

#A stream fingerprint is the hash of an empty string

The stream was already at its end. Streams are read from their current position and never rewound: call rewind($stream) first.

#MissingExtensionException for unicode()

Install ext-intl, or composer require symfony/polyfill-intl-normalizer.

Contributors

Amazing people who made this package possible

This package wouldn't exist without the contributions of our amazing community. A huge thank you to everyone who has helped improve Fingerprint!

Moamen Eltouny

@MoamenEltouny

55 contributions

Want to Contribute?

We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.