web3p/rlp
web3p/rlp is a PHP library for Recursive Length Prefix (RLP) encoding and decoding, the serialization format used throughout Ethereum. Encode arrays, strings, and integers, or decode 0x-prefixed hex payloads, through a simple API. MIT licensed and installable via Composer.
Install and smoke-test.
composer require web3p/rlp
Ignore the README's "set minimum stability to dev" line — that is a leftover from the pre-1.0 era. 0.4.x is tagged and stable, so a plain composer require web3p/rlp (or a pinned "web3p/rlp": "^0.4") works. The package is pure PHP with no extensions, tested on PHP 7.1–8.4.
Verify in php artisan tinker:
use Web3p\RLP\RLP;
$rlp = new RLP();
$rlp->encode('dog'); // "83646f67"
$rlp->encode(['dog']); // "c483646f67"
$rlp->encode(''); // "80"
$rlp->encode([]); // "c0"
$rlp->decode('0xc483646f67'); // ["646f67"]
Register it in the container. There is no facade and no config; the class is stateless, so a singleton binding is enough:
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->singleton(\Web3p\RLP\RLP::class, fn () => new \Web3p\RLP\RLP());
}
First use case you will hit: building the signing payload for an Ethereum transaction. Everything before the signature — nonce, gas fields, to, value, data, chain id — has to be RLP-serialized, hashed with keccak256, and signed. This package does only the serialization step:
$payload = $rlp->encode([
'0x' . dechex($chainId), // chain id
'0x' . dechex($nonce), // nonce
'0x' . dechex($maxPriorityFeePerGas),
'0x' . dechex($maxFeePerGas),
'0x' . dechex($gasLimit),
$to, // 0x-prefixed 20-byte address, or '' for contract creation
'0x' . dechex($valueWei),
$data, // 0x-prefixed calldata
[], // access list
]);
// $payload is hex WITHOUT 0x — hash that with a keccak256 lib, then sign with secp256k1.
If you want the whole transaction object rather than raw bytes, use web3p/ethereum-tx, which wraps this package. Reach for web3p/rlp directly when you are decoding already-signed raw transactions or assembling custom payloads.
Wrap it in a single application service, not scattered calls. The bare API is four methods wide (encode, decode, Str::encode, Str::decodeHex) but every caller needs the same guards. One seam pays for itself in tests:
namespace App\Services;
use Web3p\RLP\RLP;
final class Rlp
{
public function __construct(private RLP $rlp) {}
/** @return string hex without 0x */
public function encode(mixed $value): string
{
return $this->rlp->encode($value);
}
/** @return string hex WITH 0x, ready for JSON-RPC */
public function encodeRpc(mixed $value): string
{
return '0x' . $this->rlp->encode($value);
}
/** @return string[] hex without 0x */
public function decode(string $hex): array
{
return $this->rlp->decode(str_starts_with($hex, '0x') ? $hex : '0x' . $hex);
}
}
Model quantity vs data explicitly. This is the single most important day-to-day decision, because RLP itself does not distinguish them — Ethereum does:
int or a canonical 0x hex string with no leading zeros. Zero must be passed as 0, which encodes to 80. Passing '0x00' encodes a one-byte string 00 and will produce an invalid transaction.0x-prefixed hex string of the correct length. 0x0000…0001 (20 bytes) encodes as 94 + 20 bytes; leading zero bytes survive as of 0.4.0.Encode a TransactionData DTO into that array in one place, and unit-test it against known vectors rather than asserting on the string inside controllers.
Decode raw transactions with positional destructuring and padding. decode() returns an array of hex strings with leading zero bytes stripped, so every fixed-width field needs re-padding:
[$nonce, $gasPrice, $gasLimit, $to, $value, $data, $v, $r, $s] = $rlp->decode($rawTxHex);
$to = str_pad($to, 40, '0', STR_PAD_LEFT); // 20-byte address
$r = str_pad($r, 64, '0', STR_PAD_LEFT); // 32-byte signature component
Wrap this in a RawTransaction::fromHex() value object so padding bugs surface in one test instead of five call sites. Remember EIP-1559 raw transactions have a different shape (chainId, nonce, maxPriorityFee, maxFee, gasLimit, to, value, data, accessList, yParity, r, s) — branch on the first byte (0x01/0x02 typed envelope vs >= 0xc0 legacy list).
Validate at the boundary. Hex strings from HTTP requests are untrusted. A form-request rule plus a try/catch around decode() is the standard shape, because 0.4.0 made the decoder strict:
try {
$fields = $rlp->decode($request->string('raw_tx')->toString());
} catch (\InvalidArgumentException|\RuntimeException $e) {
throw ValidationException::withMessages(['raw_tx' => 'Malformed RLP payload.']);
}
Use Str for the display layer. Str::decodeHex($hex) turns a decoded field into a UTF-8 string; Str::encode() goes the other way. Do not call hex2bin() directly on untrusted decoded data and echo it — decoded values can contain arbitrary bytes.
Testing pattern: keep a table of cross-language vectors (ethereumjs/rlp produces the same bytes) — ''→80, []→c0, 0→80, 'dog'→83646f67, ['cat','dog']→c88363617483646f67, and a known mainnet raw transaction round-trip. That table catches almost every regression when you bump versions.
0x handling. encode() returns hex without 0x; decode() requires 0x (odd-length input throws). Every "invalid hex" bug I've seen with this package traces back to this. Prefix at the service boundary, not at each call site.decode() always returns an array, even for a top-level scalar: decode('0x83646f67') gives ['646f67'], not '646f67'. Destructure with that in mind.0x00…01 comes back as 1. Always str_pad() fixed-width fields before persisting or comparing. Conversely, do not re-encode a decoded value straight back without re-padding — round-tripping is not byte-identical for data fields.'12345' is interpreted as a number; '0x3039' is a hex string. Never pass a decimal numeric string intending bytes — a "hash" that happens to look numeric will be mangled. Always use 0x for data.PHP_INT_MAX must be passed as a 0x hex string; decimals throw InvalidArgumentException in 0.4.0. WeChat-style amounts in wei routinely exceed this — use bcmath/gmp to convert, then pass hex.-1, 1.5, or a float in exponent form, 0.4.0 rejects them instead of silently producing 80. Add a cast/validation layer rather than relying on implicit coercion.RuntimeException. This is correct per the spec, but it means payloads produced by buggy third-party encoders that previously "worked" will now fail. Do not loosen the decoder — fix the producer, or quarantine the edge case with an explicit, logged fallback.encode() output is not zero-prefixed and is not 0x-prefixed. When storing in MySQL use a binary column with hex2bin($encoded) (halves the size and avoids collation case-folding), and bin2hex() on read.web3p/ethereum-tx / web3p/web3.php) for a complete flow. Do not expect address checksumming or ABI encoding here.decode() on anything user-supplied.RLP and override encode(), because the recursion calls $this->encode() internally and you will break nested encoding semantics if you change the contract.ethers.js in a scratch Node process. If the byte strings match, the problem is your input modelling (quantity vs data), not the library. If they diverge, check leading-zero handling and whether you passed a decimal-looking string.How can I help you explore Laravel packages today?