weldist/laravel-pulse-md5-patch

Laravel Pulse patch for MySQL servers without a usable MD5() function — uses a plain key_hash column hashed in PHP.

Maintainers

Package info

github.com/weldist/laravel-pulse-md5-patch

pkg:composer/weldist/laravel-pulse-md5-patch

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-05-12 12:19 UTC

This package is not auto-updated.

Last update: 2026-05-12 18:38:03 UTC


README

Tests PHP Laravel Pulse License

A compatibility layer that makes Laravel Pulse run on MySQL servers where the built-in MD5() function is unavailable — most notably MySQL 9.6+ (which removed it from the server core) on managed services where you cannot install the Legacy Hashing component.

A weld.ist project.

Unofficial. Not affiliated with Laravel.

The Problem

Laravel Pulse's default migration defines the key_hash column on every pulse_* table as a generated column built from md5():

$table->char('key_hash', 16)->storedAs('unhex(md5(`key`))');

As of MySQL 9.6 (released January 2026), the MD5() and SHA1() SQL functions have been removed from the server core and relocated to a separate, optional Legacy Hashing component (component_legacy_hashing). On a server where that component is not installed, md5() simply does not exist — which breaks Pulse in two ways:

  • Migration fails — or the server won't start. php artisan migrate dies on the first pulse_* table because the generated-column expression references a missing function. Worse, in our case the pulse_* tables already existed: after the server was upgraded to 9.6 without the component, MySQL itself refused to start until we dropped them.
  • Storage layer assumes a generated column. Even if you hand-patch the schema, Pulse's DatabaseStorage only writes key and expects the database to derive key_hash. Drop the generated-column expression and the hash is silently NULL → unique constraints and upserts break.

The official remedy is to install the Legacy Hashing component:

INSTALL COMPONENT 'file://component_legacy_hashing';

But on many managed/hosted MySQL offerings you can't. They don't expose INSTALL COMPONENT or the filesystem path it reads from, so there's no way to restore MD5(). (Some of these services also reject md5() inside generated-column expressions outright — ERROR 3758 (HY000): Expression of generated column 'key_hash' contains a disallowed function: md5. — so the generated-column approach never worked there, even on older MySQL versions where MD5() itself was still present.)

  • Patching by hand is brittle: every Pulse upgrade re-publishes the original migration and you re-fight the same error.

The Solution

This package swaps both halves of the assumption:

  • Migration — ships a replacement for Pulse's default in which key_hash is a plain, non-generated column (char(32) on MySQL/MariaDB, uuid on PostgreSQL, string on SQLite).
  • Storage — binds a PulseDatabaseStorage that extends Pulse's DatabaseStorage and only flips one switch: requiresManualKeyHash() returns true, so the md5() of key is computed in PHP and written alongside it.
Pulse default:   INSERT (key)            → DB derives key_hash via unhex(md5(key))   ✗ when MD5() is unavailable
This package:    INSERT (key, key_hash)  → key_hash = md5(key) computed in PHP       ✓

Nothing else in Pulse changes — dashboards, recorders, aggregation, trimming all behave exactly as before.

Requirements

  • PHP ^8.1
  • laravel/pulse ^1.0

No MySQL version constraint — it works on 8.x and on 9.6+ (with or without the Legacy Hashing component), since the hash never touches a server-side function.

Installation

composer require weldist/laravel-pulse-md5-patch

The service provider is auto-discovered — no manual registration.

Setup

Publish this package's migration and run it instead of Pulse's own:

php artisan vendor:publish --tag=pulse-md5-patch-migrations
php artisan migrate

Do not publish or run php artisan vendor:publish --tag=pulse-migrations. The two migrations create the same tables and cannot coexist. If you previously ran Pulse's migration, drop the pulse_values, pulse_entries, and pulse_aggregates tables before migrating this one.

That's it.

How It Works

Default Pulse This package
key_hash column (MySQL/MariaDB) char(16) charset binary — generated via unhex(md5(key)) char(32) — populated by PHP md5()
key_hash column (PostgreSQL) uuid — generated via md5("key")::uuid uuidmd5("key")::uuid (unchanged)
key_hash column (SQLite) string string
Storage class DatabaseStorage PulseDatabaseStorage (extends DatabaseStorage)
key_hash source database expression PHP md5() (requiresManualKeyHash() === true)
Storage binding PulseServiceProvider::register() overridden in boot(), always wins

Why bind in boot() instead of register()?

PulseServiceProvider binds Storage::class → DatabaseStorage::class inside its register() method. Re-binding in boot() — which always runs after every provider's register() — guarantees the override takes effect regardless of provider load order.

Why a plain char(32) instead of a generated column?

When MD5() isn't available server-side (MySQL 9.6+ without the Legacy Hashing component) — and on managed services that additionally forbid md5() inside generated-column expressions — there is no generated-column variant that works. Storing the 32-char hex output of PHP's md5() directly side-steps database-level computation entirely. (char(16) BINARY would also work for raw bytes, but char(32) keeps the value human-readable and matches what requiresManualKeyHash() emits.)

Minimal override

PulseDatabaseStorage overrides exactly one method — requiresManualKeyHash(). Every other line of Pulse's storage logic is inherited untouched, so upgrades to laravel/pulse carry over with no extra work.

Testing

The suite runs against a real MySQL server — the schema and storage behaviour this package changes only matter on MySQL, so SQLite would prove nothing. docker compose brings up a mysql:8.4 container alongside the PHP container automatically; you don't need a local MySQL.

# Build first (once per PHP version)
DOCKER_BUILDKIT=0 docker compose --profile php81 build

# Run tests (each profile starts its own MySQL)
docker compose --profile php81 up --abort-on-container-exit --exit-code-from php81   # PHP 8.1
docker compose --profile php82 up --abort-on-container-exit --exit-code-from php82   # PHP 8.2
docker compose --profile php83 up --abort-on-container-exit --exit-code-from php83   # PHP 8.3
docker compose --profile php84 up --abort-on-container-exit --exit-code-from php84   # PHP 8.4
docker compose --profile php85 up --abort-on-container-exit --exit-code-from php85   # PHP 8.5

# Run a single test class or method
docker compose --profile php81 run --rm php81 vendor/bin/phpunit --filter PulseStorageMysqlTest
docker compose --profile php81 run --rm php81 vendor/bin/phpunit --filter test_value_is_persisted_with_php_computed_key_hash

Running vendor/bin/phpunit directly (outside Docker) works too — point it at a MySQL instance via DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD (defaults: 127.0.0.1:3306, db/user/pass all testing).

CI runs PHP 8.1–8.5 via .github/workflows/tests.yml.

License

This package is open-sourced software licensed under the MIT license.