PHP Packagev8.0.1MIT 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, 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.

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.0.x (each 8.x release line targets the matching PHP version)
  • 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.

Enums

The 8.0.x line runs on PHP 8.0, which has no enums, so it has no enum support of any kind.

Installation Complete

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 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, such as Paymob: see Ordered Signatures, which has a complete step-by-step example.

#Good to Know

  • 7 and '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]); // true
2Fingerprint::make([1, 2]) === Fingerprint::make([2, 1]); // false

Use 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.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

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 string
3 ->at('email')->lower() // only email
4 ->at('roles')->unordered() // only roles
5 ->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() and upper() are one kind, and so are unordered(), sortBy() and sortUsing(), and unique() and uniqueBy().
  • 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:

  1. objects become plain data (Objects);
  2. ignoreWhen() callbacks;
  3. transform(), whose result is converted again;
  4. string rules: trim(), normalizeWhitespace(), unicode(), then lower() or upper();
  5. normalizeNumericStrings(), then precision();
  6. normalizeBooleans();
  7. date rules: datePrecision() and keepTimezone();
  8. the value's children;
  9. 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([
2 'user' => ['email' => '[email protected]', 'name' => 'Ahmed'],
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.

Filters and lists

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(); // 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']

#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']

#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:

  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. JsonSerializable uses what jsonSerialize() returns;
  5. Traversable (ArrayObject, ArrayIterator, IteratorAggregate…) is iterated;
  6. stdClass becomes a map, even when empty or numerically keyed;
  7. 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.
Non-cryptographic algorithms

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 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.
  • 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.

#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.
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.

#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 signature
10$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&currency=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):

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}

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:

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}

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.

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.

#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()

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 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.
  • null differs from a missing key unless you use ignoreNulls().
  • The order of only() paths never matters. The order of signature fields() 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* or murmur3*.
  • 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
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:

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}

#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:

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.

#3. De-duplicating Imported Contacts

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.

#4. Signing and Verifying Webhooks

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'] ?? '');

#5. Checking Downloaded Files

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}

#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.

#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!

Moamen Eltouny

@MoamenEltouny

22 contributions

Want to Contribute?

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