Moderar comentarios en Laravel con Laratox
Una regla de validación, una facade y un fake: construye en Laravel un muro de comentarios que publica, retiene o rechaza cada comentario, y publica los retenidos cuando una persona los aprueba.
El formulario de comentarios es la primera puerta por la que entran el spam y el abuso, y lo último que alguien quiere leerse entrada a entrada. En este artículo montamos un pequeño muro de comentarios en Laravel, con Laratox, y lo moderamos con ToxicFilter: cada comentario se comprueba antes de mostrarse, y pasa una de estas tres cosas.
- Allow: se publica al momento. Es lo que pasa con casi todos.
- Review: se retiene, y una persona decide en tu panel de ToxicFilter. Un webhook firmado avisa a la aplicación, que lo publica o lo descarta.
- Block: se rechaza, y al autor se le explica el motivo con palabras.
Tres resultados y no dos, a propósito. Si te obligan a elegir entre publicar y borrar, un umbral estricto borra comentarios legítimos y uno permisivo publica el abuso; en el resultado intermedio, los casos dudosos esperan a que los vea una persona. La aplicación completa está en toxicfilter/laravel-example.
Este artículo forma parte de una serie de cuatro que monta la misma aplicación, cada vez con un cliente distinto: PHP sin framework, Flask y Express. Cada una tiene su propio repositorio, listo para clonar o para usar como plantilla: php-example, flask-example, express-example y laravel-example.
Laratox envuelve el SDK de PHP para Laravel: una regla de validación, una facade que se lee como una frase y un fake para tus tests. Con él, moderar un comentario es una regla más en la petición que ya validas.
Instalación y configuración
composer require edulazaro/laratox
Crea una clave en tu panel; el plan gratuito basta. Una clave tf_test_ no cuesta nada y ejecuta todas las comprobaciones gratuitas, que son las que resuelven la mayoría de los comentarios, pero nunca consulta al modelo: usa una clave live para ver lo que aporta el modelo. La clave da acceso a toda la cuenta, así que va en una variable de entorno del servidor y nunca en una página.
TOXICFILTER_KEY=tf_test_...
TOXICFILTER_WEBHOOK_SECRET=whsec_...
Comprueba que la clave funciona antes de escribir código:
php artisan laratox:ping
Moderar un comentario en la validación
$id = $this->comments->nextId();
$rule = Moderated::text()->surface('comment')->reference("comment_{$id}");
$data = $request->validate([
'name' => ['required', 'string', 'max:60'],
'body' => ['bail', 'required', 'string', 'max:2000', $rule],
]);
// No verdict means the API could not answer and the rule let the field through
// (`laratox.rule.on_error` is `allow` by default). Hold it rather than publish it unread.
$verdict = $rule->verdict();
$held = $verdict === null || $verdict->needsReview();
$this->comments->add($id, $data['name'], $data['body'], $held ? 'held' : 'published', $verdict?->id());
return redirect()->route('wall')->with('held', $held);
- Un block hace fallar el campo como cualquier otra regla, con el motivo de ToxicFilter en el mensaje: "The body could not be accepted: contains a referral link." El formulario lo muestra con
@error('body'), nada nuevo que aprender. - Un review pasa la validación, y la regla guarda el veredicto, así que el controlador pregunta después a
$rule->verdict()y retiene el comentario. Con->orReview(), en cambio, el campo falla. - La regla encadena las mismas opciones que la facade:
surface()dice dónde aparece el texto,reference()lleva el id propio de la aplicación para que el webhook encuentre el comentario más tarde, ypolicy(),project()ylocale()están disponibles para cuando los necesites. - Si la API no puede responder, la regla deja pasar el campo (
laratox.rule.on_erroresallowpor defecto, y lo registra en el log) y no hay veredicto, así que el controlador retiene el comentario. Conrefuse, en cambio, el campo falla. - Pon la regla en último lugar, después de
bail: es una llamada de red, y un campo que ya ha fallado una regla más barata no la necesita.
Fuera de un formulario, la facade hace lo mismo en una línea:
$verdict = ToxicFilter::text($comment->body)->surface('comment')->check();
if ($verdict->blocked()) {
return back()->withErrors(['body' => $verdict->reason()]);
}
Cuando decide una persona: el webhook
Un comentario retenido espera en tu cola de revisión. Cuando alguien lo aprueba o lo rechaza, ToxicFilter envía moderation.resolved, firmado. El handler usa el verificador del SDK, que Laratox trae como dependencia:
$event = Webhooks::event(
$request->getContent(), // the RAW body
(string) $request->header('X-ToxicFilter-Signature', ''),
(string) config('services.toxicfilter.webhook_secret'),
);
if ($event === null) {
return response('', 400);
}
if ($event['event'] === 'moderation.resolved' && preg_match('/^comment_(\d+)$/', $event['data']['reference'] ?? '', $m)) {
$event['data']['action'] === 'approved'
? $comments->publish((int) $m[1])
: $comments->remove((int) $m[1]);
}
return response('', 204);
La ruta se excluye de la protección CSRF en bootstrap/app.php, porque ToxicFilter no puede enviar un token; lo que la autentica es la firma. $request->getContent() es el cuerpo en bruto, que es lo que se firmó.
Para probarlo en tu máquina, expón la aplicación con un túnel (cloudflared tunnel --url http://localhost:8000, por ejemplo), añade la dirección del túnel seguida de /webhooks/toxicfilter como endpoint en Webhooks, y pon el secreto de firma que te muestra en TOXICFILTER_WEBHOOK_SECRET. Después publica un comentario que acabe en revisión, apruébalo desde la cola de revisión y verás cómo aparece.
Hay dos detalles que hacen que el handler no solo funcione, sino que sea correcto. La firma se comprueba sobre el cuerpo en bruto, porque un cuerpo parseado y vuelto a codificar ya es otra cadena y la firma nunca cuadraría. Y el comentario se encuentra por su reference, el id que la aplicación envió con la comprobación, porque el webhook lleva el veredicto y nunca el comentario: ToxicFilter no guarda lo que modera.
Tests sin llamar a la API
ToxicFilter::fake() sustituye solo la red: el cliente del SDK y sus veredictos son los reales, así que un test pasa por el mismo código que corre en producción. Dile qué responder y comprueba lo que se envió:
public function test_an_allowed_comment_is_published(): void
{
$fake = ToxicFilter::fake();
$this->post('/comments', ['name' => 'Ana', 'body' => 'Lovely post, thanks.'])->assertRedirect('/');
$this->get('/')->assertSee('Lovely post, thanks.');
$fake->assertSent(fn ($request) => $request['body']['reference'] === 'comment_1');
}
public function test_a_comment_in_review_is_held(): void
{
ToxicFilter::fake()->shouldReview('toxicity', 'Contempt aimed at the reader.');
$this->post('/comments', ['name' => 'Bo', 'body' => 'You clearly know nothing.'])
->assertRedirect('/')
->assertSessionHas('held', true);
$this->get('/')->assertDontSee('You clearly know nothing.');
$this->assertSame('held', app(Comments::class)->find(1)['status']);
}
La suite de la demo cubre así todos los caminos, el webhook firmado incluido, y se ejecuta en una fracción de segundo sin clave.
Cuando la API no puede responder
Fuera de la regla, check() lanza las excepciones del SDK, que extienden todas ApiError: QuotaExhausted cuando la cuenta se ha quedado sin créditos (nunca se reintenta), RateLimited y ServerError después de los reintentos, InvalidRequest con los campos que se rechazaron. Las llamadas pasan por el cliente HTTP de Laravel, así que Http::fake() y tu sistema de logs las ve, y un reintento lleva la misma clave de idempotencia, así que se juzga y se cobra una sola vez.
Por dónde seguir
- Tus propias reglas: una política mueve los umbrales por categoría, añade tus propias palabras prohibidas o mide temas como el juego o las criptomonedas, que no son dañinos pero puede que no encajen en tu sitio. Indica cuál en la llamada.
- Varios sitios en una cuenta: dale a cada uno un proyecto, con su propia actividad, su cola de revisión y sus webhooks.
- Más allá del texto: el mismo cliente comprueba imágenes, nombres de usuario, registros completos y conversaciones, donde salen a la luz un acoso en grupo o un acercamiento que ningún mensaje deja ver por separado.
Sigue leyendo
Moderar comentarios en Express con el SDK de JavaScript
Construye en Express un muro de comentarios que publica, retiene o rechaza cada comentario con su motivo, mant...
Moderar comentarios en Flask con el SDK de Python
Monta un muro de comentarios en Flask que publica, retiene o rechaza cada comentario con su motivo, verifica e...
Moderar comentarios en PHP sin framework con el SDK de ToxicFilter
Sin framework: un muro de comentarios en PHP puro que publica, retiene o rechaza cada comentario, explica al a...