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 it4Fingerprint::verify($product, $fingerprint); // true while nothing changedIt also verifies payment gateway and webhook signatures, such as Paymob callbacks, with Fingerprint::signature(). See Ordered Signatures.
New here? Start with the Quick Start: three methods cover most uses. The Advanced pages are there when you need more control.
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.
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.0.x (each
8.xrelease line targets the matching PHP version) ext-mbstringext-intlorsymfony/polyfill-intl-normalizer, only if you use theunicode()rule
There are no other runtime dependencies.
#Composer Installation
composer require pharaonic/php-fingerprintComposer autoloads the Pharaonic\Fingerprint namespace.
The 8.0.x line runs on PHP 8.0, which has no enums, so it has no enum support of any kind.
You're all set! Try Pharaonic\Fingerprint\Fingerprint::make(['id' => 7]), which returns a 64-character SHA-256 hex string.
#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 changed3}#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 fingerprint5}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, such as Paymob: see Ordered Signatures, which has a complete step-by-step example.
#Good to Know
7and'7'are different, and so are[1, 2]and[2, 1]. Read Canonical Rules when two values you expect to match don't.- Builders never change: each method returns a new one, so you can keep a configured builder and reuse it with
->for($otherData). - When you need more, such as trimming strings, rounding prices or ignoring list order, the Advanced pages cover it.
#Canonical Rules
Read this when two values you expect to match get different fingerprints, or the other way round.
Before hashing, every value is written in a canonical form (format v1). These rules decide when two values share a fingerprint.
#Maps and Lists
- An array whose keys are exactly
0, 1, 2, …in that order is a list. Its order matters. - Any other array is a map (associative array). Its keys are ordered canonically at every level, so key order never matters.
[]is an empty list.- 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.
1Fingerprint::make(['b' => 1, 'a' => 2]) === Fingerprint::make(['a' => 2, 'b' => 1]); // true2Fingerprint::make([1, 2]) === Fingerprint::make([2, 1]); // falseUse unordered() when a list's order doesn't matter to you.
#Types
Types are preserved: 1, '1', 1.0 and true are four different values, and so are 0, '0', 0.0, false, '', [] and null. Use normalizeNumericStrings() or normalizeBooleans() to treat some of them as equal.
#Null and Missing Keys
['a' => null] and [] give different fingerprints. Use ignoreNulls() to leave nulls out.
#Floats
- Floats are encoded from their exact IEEE-754 bits, so
0.1 + 0.2and0.3differ. Useprecision()to round them. -0.0is the same as0.0.INFand-INFare supported.NANthrows anUnsupportedValueException, because it is not equal to itself.
#Strings
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: [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.
#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".#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:
1Fingerprint::for($data)->namespace('invoice')->make();A namespace is not a secret. Use hmac() when the fingerprint must not be forgeable.
#Paths & Wildcards
Read this when you need to target nested values, like every item's price, or apply a rule to one field only.
ignore(), only(), at(), sortBy(), uniqueBy() and the signature fields() take dot paths. The syntax is the same as in pharaonic/php-dot-array.
#Syntax
| Path | Matches |
|---|---|
user.email |
$value['user']['email'] |
items.0.price |
the price of the first item |
items.*.price |
the price of every item: * matches any one key or list index |
*.id |
the id of every top-level entry |
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. A path that matches nothing is simply a no-op.
Paths always refer to the input keys. When a filter removes items.1, the next element is still items.2 for every other rule, even though the list is re-indexed in the result.
#Scoping Rules With at()
Value rules (trim(), lower(), precision(), transform(), unordered()…) apply to every value until you call at(). After at(), they apply to the values at that path, until the next at():
1Fingerprint::for($user)2 ->trim() // every string3 ->at('email')->lower() // only email4 ->at('roles')->unordered() // only roles5 ->make();- 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.
lower()andupper()are one kind, and so areunordered(),sortBy()andsortUsing(), andunique()anduniqueBy(). - A global rule also applies to the root value itself.
#Rule Order
Rules always run in the same order, whatever order you declare them in, so ->lower()->trim() and ->trim()->lower() are the same:
- objects become plain data (Objects);
ignoreWhen()callbacks;transform(), whose result is converted again;- string rules:
trim(),normalizeWhitespace(),unicode(), thenlower()orupper(); normalizeNumericStrings(), thenprecision();normalizeBooleans();- date rules:
datePrecision()andkeepTimezone(); - the value's children;
- list rules: ordering, then de-duplication.
#Filtering
ignore() is covered in the Quick Start. Read on for selecting fields, ignoring by condition, and dropping empty values.
Filters decide which values take part in the fingerprint. They are global: at() does not scope them.
#ignore()
Leave out the values at one path or several. Wildcards remove every match:
1Fingerprint::for($order)->ignore('updated_at')->make();2 3Fingerprint::for($order)->ignore(['updated_at', 'items.*.cached_label'])->make();#only()
Keep only the values at these paths, everything under them, and the containers leading to them. The order of the paths never matters, and calling only() again adds more paths:
1Fingerprint::for([3 'meta' => ['seen' => 3],4])->only(['user.email'])->result()->normalized();5// ['user' => ['email' => '[email protected]']]A container that ends up with no selected value is left out. When nothing matches at all, an array root becomes an empty map. ignore() wins over only() when both match.
#ignoreWhen()
Leave out every value for which a callback returns true. The callback receives the value's escaped dot path and the value before any value rule applies. It 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.
#ignoreNulls(), ignoreEmptyStrings(), ignoreEmptyArrays()
These check each value after its rules apply, from the deepest values up. 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.
A list that loses elements stays a list and is re-indexed, while paths keep referring to the input indexes. A map that loses every key stays an empty map, which differs from [].
#Normalization
Read this when equal-looking data should match: extra spaces, upper and lower case, rounding, numbers stored as strings, 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().
#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(); // true2 3Fingerprint::for(0.285)->precision(2)->result()->normalized(); // 0.280.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']#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.
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']#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()
Elements are ordered by their canonical bytes, so lists that hold the same elements, duplicates included, 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['a', 'a', 'b'] and ['a', 'b'] still differ. Add unique() when duplicates don't matter either.
#sortBy()
Order structured elements 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');Ties keep their input order. Values of different types are ordered by type: a missing field, then null, booleans, numbers, strings, then arrays.
#sortUsing()
Order elements with a 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']);#unique() and uniqueBy()
unique() removes repeated elements, compared strictly, so 1 and '1' are both kept. uniqueBy($field) removes elements that repeat a field's value. Both keep the first occurrence:
1Fingerprint::for([1, '1', 1, 2])->unique()->result()->normalized(); // [1, '1', 2]2 3Fingerprint::for([['id' => 1, 'v' => 'a'], ['id' => 1, 'v' => 'b']])4 ->uniqueBy('id')5 ->result()->normalized();6// [['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().
#Which Rule Wins
unordered(), sortBy() and sortUsing() replace each other for a path, and so do unique() and uniqueBy(). The last one declared wins.
#Objects
Read this when your data holds objects other than plain arrays: models, value objects, collections.
Objects are converted into plain data, in this order:
- closures and generators are rejected;
- a
normalize()callback for the object's class or one of its types; DateTimeInterfacestays a date (see Dates);JsonSerializableuses whatjsonSerialize()returns;Traversable(ArrayObject,ArrayIterator,IteratorAggregate…) is iterated;stdClassbecomes a map, even when empty or numerically keyed;- 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.
#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.
#Hashing & HMAC
The default (SHA-256, hex) fits most uses. Read this when the fingerprint must be secret, shorter, or in another format.
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]An algorithm PHP doesn't have throws a ConfigurationException instead of falling back. On PHP 8.0, that includes xxh32, xxh64, xxh3, xxh128 and murmur3*, which arrived in PHP 8.1:
1Fingerprint::for($data)->algorithm('xxh128');2// ConfigurationException: The hash algorithm "xxh128" is not available on PHP 8.0.30.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(), when the fingerprint protects anything.
#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(). That way the secret never shows up in an exception trace.
#Output Encodings
| Method | Output for ['a' => 1] |
|---|---|
hex() (default) |
8007ef36e8ee76e080e880d450b79d39cd4a544ae3962f08f40d96211ae3946e |
binary() |
the 32 raw bytes |
base64() |
gAfvNujuduCA6IDUULedOc1KVErjli8I9A2WIRrjlG4= |
base64Url() |
gAfvNujuduCA6IDUULedOc1KVErjli8I9A2WIRrjlG4 (- and _, no padding) |
#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 falseFingerprint::verify($value, $expected) is the shortcut for a plain SHA-256 hex fingerprint.
#Keeping Secrets Safe
var_dump()andprint_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 aLogicException, 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. - 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 andhmac()for secrecy.
#Files & Streams
Read this to fingerprint a file or a stream's raw bytes, such as an upload or a download.
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.file() reads whatever path PHP accepts, stream wrappers included. Never pass a path that comes from user input without validating it first.
#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. It is a separate pipeline from fingerprints: nothing is sorted, and the order of fields() is the order of the message.
1use Pharaonic\Fingerprint\Fingerprint; 2 3$signature = Fingerprint::signature($payload) 4 ->fields(['amount_cents', 'currency', 'order.id', 'success']) 5 ->algorithm('sha512') 6 ->hmac($secret); 7 8$signature->message(); // the exact string that is signed 9$signature->make(); // the signature10$signature->verify($received); // constant-time check#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.
A string key renames the field in keyValue() output:
1->fields(['amount' => 'order.amount_cents', 'currency'])#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¤cy=EGP&paid=true"#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 |
| 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 8.0 turns floats into text differently depending on the precision ini setting. Use at() to apply trim(), normalizeWhitespace(), lower(), upper(), unicode(), precision() 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 |
1->onMissing('empty')2->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.
#Example: Verifying a Paymob 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.
Step 1: what Paymob sends. The body looks like this (shortened):
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}Step 2: 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.
Step 3: do the same and compare. List the fields in Paymob's order, and let the package build the text and compare the signatures:
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. 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}Step 4: 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 the Signature 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.
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.
#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 arraycanonical()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 withkeepTimezone().
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() |
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 theprecision/serialize_precisionini settings; - on every supported PHP version and release line: canonical format v1 is the same on all of them, and the test suite checks hand-written golden vectors on each line.
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.
nulldiffers from a missing key unless you useignoreNulls().- The order of
only()paths never matters. The order of signaturefields()always does.
#Release Lines
Each release line requires exactly one PHP version and keeps canonical format v1:
| Line | PHP |
|---|---|
| 8.0.x | 8.0 |
#Limitations
- Enums: the 8.0.x line has no enum support, because PHP 8.0 has no enums.
- Hash algorithms: only the algorithms of your PHP build are available. PHP 8.0 has no
xxh*ormurmur3*. - Unicode tables:
lower(),upper()andunicode()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'to1before 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
1and1.0apart, 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 |
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(), 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
#1. Skip Unchanged Records in a Sync
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:
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}#2. Idempotency Keys
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:
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.
#3. De-duplicating Imported Contacts
Contacts that differ only in whitespace, case or the order of their phone numbers get the same fingerprint:
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.
#4. Signing and Verifying Webhooks
The sender and the receiver build the same ordered message from the same fields:
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]);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'] ?? '');#5. Checking Downloaded Files
Compare a download with the checksum the publisher lists, without loading the file into memory:
1use Pharaonic\Fingerprint\Fingerprint;2 3if (! Fingerprint::file($archive)->algorithm('sha512')->verify($publishedChecksum)) {4 throw new RuntimeException('The download is corrupted.');5}#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 nextat(). Declare global rules first. - For the same kind of rule, the last one wins for a path: a later
upper()replaces an earlierlower(). - Paths refer to input keys: a key with a dot in its name needs
\., as inconfig.app\.name.
#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. PHP 8.0 has no xxh* or murmur3*. 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!
Want to Contribute?
We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.