Los tokens de un solo uso deben sobrevivir a un rechazo
Crear exige prueba de que el correo fue verificado, y esa prueba es de un solo uso:
$verificationPayload = Cache::pull("verified-email:{$verificationToken}");
if (! is_array($verificationPayload)) {
return Response::json([
'error' => 'Invalid or expired verification token. Email must be verified first.',
]);
}
Cache::pull lee y borra atómicamente: el token no puede gastarse dos veces, ni siquiera por dos llamantes simultáneos.
Lo que crea un problema en cuanto algo posterior falla. El token ya no está, el sitio no se creó, y al usuario se le pide que mire su correo y vuelva a escribir un código de seis dígitos por un fallo que fue enteramente nuestro.
Así que un intento rechazado lo devuelve:
// The single-use token was already pulled above; restore it so a rejected
// duplicate doesn't force the user to re-verify their email.
private function restoreVerificationToken(string $token, array $payload): void
{
Cache::put("verified-email:{$token}", $payload, now()->addMinutes(30));
}
Una credencial de un solo uso necesita un dueño durante toda su vida. Entre sacarla y completar el trabajo, la tienes tú —y si no terminas, la devuelves. Todos los caminos de fallo de esta herramienta la restauran.
Nunca mantengas una transacción a través de una llamada de red
// Call the platform OUTSIDE any DB transaction — a slow or hung upstream must
// never hold a database connection/transaction open for its duration.
try {
$result = app(WebploService::class)->createSite(data: $validated);
} catch (Throwable $e) {
// …
}
La versión de aspecto pulcro envuelve toda la herramienta en DB::transaction() para que un fallo revierta limpiamente. Es una trampa.
Una llamada movida por IA puede tardar un minuto. Una transacción abierta un minuto retiene una conexión, retiene bloqueos y bloquea cualquier cosa que toque esas filas —y un puñado de creaciones simultáneas agota el pool. La base de datos deja de servir a toda la aplicación porque un servicio externo va lento.
La alternativa es aceptar una pequeña ventana de estado intermedio y limpiarla explícitamente. Lo que exige cuidado con el orden.
Revierte antes de reportar
} catch (Throwable $e) {
// Roll back and restore the token FIRST so a failure in reporting can
// never leave the user with a burned token and an orphaned row.
$provisioning->delete();
$this->restoreVerificationToken($verificationToken, $verificationPayload);
report($e);
return Response::json([
'error' => 'Site creation is temporarily unavailable. Please try again in a moment.',
]);
}
report() parece lo primero que deberías hacer. Es lo último.
report() no está libre de fallar: puede dar contra un servicio de registro caído, una cola llena o un manejador que lanza. Si va primero y falla, las acciones compensatorias no se ejecutan nunca —y al usuario le queda un token de verificación quemado y una fila huérfana ocupando su URL. No puede reintentar, porque la fila que creó le está bloqueando ahora.
Ordena las acciones compensatorias antes que la observabilidad. Deshaz el estado y luego escribe sobre ello. La línea de registro es para ti; la limpieza es para el usuario.
Fíjate además en que el mensaje devuelto es genérico y alentador, mientras que el error real va a report(). El modelo no necesita la excepción: necesita saber si sugerir volver a intentarlo.