hash_hmac is the one to reach for, not hash

Signing a payload by hashing the secret and the message together is a construction with known weaknesses, and it keeps getting written because it looks obviously correct.

// vulnerable to length extension on some algorithms
$sig = hash('sha256', $secret . $payload);

// what to write instead
$sig = hash_hmac('sha256', $payload, $secret);

// and the comparison, which matters just as much
if (! hash_equals($expected, $provided)) {
    throw new InvalidSignature();
}

HMAC is a specific construction designed for exactly this, and it costs the same to call. The comparison is the half that gets forgotten: === on two strings returns as soon as it finds a differing byte, and that timing is measurable over enough requests. hash_equals takes constant time for equal-length strings and exists for no other reason.