Communitygithub.com

laravel-tdd

Stratégies de test Laravel avec PHPUnit, Pest, model factories, tests HTTP, tests d'authentification Sanctum, mocking et couverture. À utiliser lors de l'écriture de tests Laravel avec PHPUnit ou Pest, ou en conduisant un test fonctionnel Laravel en premier.

Qu'est-ce que laravel-tdd ?

Cette compétence fournit un guide complet pour le développement piloté par les tests (TDD) dans Laravel, couvrant PHPUnit et Pest. Elle détaille la configuration de l'infrastructure de test (phpunit.xml, TestCase), la création et l'utilisation de model factories (y compris les états et séquences), les tests unitaires pour les modèles, les relations, les scopes et les accesseurs, ainsi que les tests fonctionnels pour les contrôleurs HTTP et les API JSON. La compétence inclut des exemples de test de l'authentification avec Sanctum, le mocking de services externes (HTTP, mail, notifications, files d'attente, stockage et événements) avec des fakes, et les tests des commandes Artisan et de l'autorisation. Elle est utile pour tout développeur Laravel qui souhaite adopter une approche de test d'abord et garantir la qualité de son code, en s'appuyant sur les outils officiels de Laravel et sur les bonnes pratiques TDD.

Compatible avec~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/affaan-m/everything-claude-code/tree/main/skills/laravel-tdd

Demander à votre IA préférée

Ouvre une nouvelle conversation avec cette compétence d'agent déjà préchargée.

Documentation

Tests Laravel avec TDD

Développement piloté par les tests pour les applications Laravel à l'aide de PHPUnit, Pest, Laravel factories et des helpers de test.

Quand activer

  • Écrire de nouvelles applications ou fonctionnalités Laravel
  • Implémenter des points d'accès API avec l'authentification Sanctum ou Passport
  • Tester les modèles Eloquent, les relations, les scopes et les accesseurs
  • Mettre en place l'infrastructure de test pour les projets Laravel
  • Écrire des tests fonctionnels pour les contrôleurs HTTP et les requêtes de formulaire
  • Simuler des services externes (files d'attente, courrier, notifications, HTTP)

Workflow TDD pour Laravel

Cycle Rouge-Vert-Réusinage

// Étape 1 : ROUGE — Écrire un test qui échoue
public function test_un_produit_peut_etre_cree(): void
{
    $produit = Product::factory()->create(['nom' => 'Produit test']);
    $this->assertDatabaseHas('products', ['nom' => 'Produit test']);
}

// Étape 2 : VERT — Écrire la migration, le modèle et la factory
// Étape 3 : RÉUSINAGE — Améliorer tout en gardant les tests verts

Configuration

Configuration PHPUnit

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true">
    <testsuites>
        <testsuite name="Unité">
            <directory suffix="Test.php">tests/Unit</directory>
        </testsuite>
        <testsuite name="Fonctionnel">
            <directory suffix="Test.php">tests/Feature</directory>
        </testsuite>
    </testsuites>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="BCRYPT_ROUNDS" value="4"/>
        <env name="CACHE_STORE" value="array"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="MAIL_MAILER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
    </php>
</phpunit>

Configuration de la classe TestCase de base

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        // Appeler $this->withoutExceptionHandling() uniquement dans les tests qui
        // testent des exceptions non-HTTP ; cela supprime assertStatus() etc.
    }

    // Helper : Authentifier et retourner un utilisateur
    protected function actingAsUser(): mixed
    {
        $user = \App\Models\User::factory()->create();
        $this->actingAs($user);
        return $user;
    }

    protected function actingAsAdmin(): mixed
    {
        $admin = \App\Models\User::factory()->admin()->create();
        $this->actingAs($admin);
        return $admin;
    }
}

Model Factories

// database/factories/UserFactory.php
class UserFactory extends Factory
{
    protected static ?string $password = null;

    public function definition(): array
    {
        return [
            'nom' => fake()->name(),
            'email' => fake()->unique()->safeEmail(),
            'email_verified_at' => now(),
            'mot_de_passe' => static::$password ??= Hash::make('password'),
            'remember_token' => Str::random(10),
            'role' => 'utilisateur',
        ];
    }

    public function admin(): static
    {
        return $this->state(fn (array $attributes) => ['role' => 'admin']);
    }

    public function non_verifie(): static
    {
        return $this->state(fn (array $attributes) => ['email_verified_at' => null]);
    }
}

// database/factories/ProductFactory.php
class ProductFactory extends Factory
{
    public function definition(): array
    {
        return [
            'nom' => fake()->unique()->words(3, true),
            'slug' => fn (array $attrs) => Str::slug($attrs['nom']),
            'description' => fake()->paragraph(),
            'prix' => fake()->numberBetween(100, 100000),
            'stock' => fake()->numberBetween(0, 100),
            'actif' => true,
            'id_utilisateur' => UserFactory::new(),
        ];
    }

    public function hors_stock(): static
    {
        return $this->state(fn (array $attributes) => ['stock' => 0]);
    }
}

Utilisation des Factories

$user = User::factory()->create();
$admin = User::factory()->admin()->create();
$produit = Product::factory()->create(['id_utilisateur' => $user->id]);
$produits = Product::factory()->count(10)->create();
$brouillon = Product::factory()->make(); // Non persisté

// Avec relations
$user = User::factory()->has(Product::factory()->count(3))->create();

// Séquences
User::factory()->count(3)->sequence(
    ['role' => 'admin'], ['role' => 'éditeur'], ['role' => 'utilisateur'],
)->create();

Tests de modèle

namespace Tests\Unit\Models;

use App\Models\User;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class UserTest extends TestCase
{
    use RefreshDatabase;

    public function test_il_cache_les_attributs_sensibles(): void
    {
        $user = User::factory()->create();
        $this->assertArrayNotHasKey('password', $user->toArray());
    }

    public function test_le_scope_admin_retourne_uniquement_les_admins(): void
    {
        User::factory()->admin()->create();
        User::factory()->count(3)->create();

        $this->assertCount(1, User::admin()->get());
    }
}

class ProductTest extends TestCase
{
    use RefreshDatabase;

    public function test_le_scope_actif_filtre_correctement(): void
    {
        Product::factory()->count(3)->create(['actif' => true]);
        Product::factory()->count(2)->create(['actif' => false]);

        $this->assertCount(3, Product::active()->get());
    }

    public function test_il_appartient_a_un_utilisateur(): void
    {
        $user = User::factory()->create();
        $produit = Product::factory()->create(['id_utilisateur' => $user->id]);

        $this->assertTrue($produit->user->is($user));
    }
}

Tests fonctionnels / HTTP

namespace Tests\Feature\Http\Controllers;

use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ProductControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_les_visiteurs_sont_rediriges_vers_la_connexion(): void
    {
        $this->get(route('products.create'))->assertRedirect(route('login'));
    }

    public function test_il_enregistre_un_nouveau_produit(): void
    {
        $user = User::factory()->create();
        $this->actingAs($user);

        $response = $this->post(route('products.store'), [
            'nom' => 'Nouveau Produit',
            'description' => 'Description',
            'prix' => 2999,
            'stock' => 10,
        ]);

        $response->assertRedirect(route('products.index'));
        $this->assertDatabaseHas('products', [
            'nom' => 'Nouveau Produit',
            'id_utilisateur' => $user->id,
        ]);
    }

    public function test_il_valide_les_champs_obligatoires(): void
    {
        $this->actingAs(User::factory()->create());
        $this->post(route('products.store'), [])
            ->assertSessionHasErrors(['nom', 'prix']);
    }

    public function test_les_utilisateurs_ne_peuvent_pas_modifier_les_produits_des_autres(): void
    {
        $proprietaire = User::factory()->create();
        $attaquant = User::factory()->create();
        $produit = Product::factory()->create(['id_utilisateur' => $proprietaire->id]);

        $this->actingAs($attaquant)
            ->delete(route('products.destroy', $produit))
            ->assertForbidden();
    }
}

Tests d'API JSON

namespace Tests\Feature\Http\Controllers\Api;

use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ProductApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_les_requetes_non_authentifiees_sont_rejetees(): void
    {
        $this->getJson('/api/produits')->assertUnauthorized();
    }

    public function test_il_liste_les_produits_pagines(): void
    {
        $user = User::factory()->create();
        Product::factory()->count(5)->create(['id_utilisateur' => $user->id]);

        $response = $this->actingAs($user)->getJson('/api/produits');

        $response->assertOk();
        $response->assertJsonCount(5, 'data');
        $response->assertJsonStructure([
            'data' => [['id', 'nom', 'prix']],
            'meta' => ['page_courante', 'derniere_page', 'total'],
        ]);
    }

    public function test_il_cree_un_produit(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->postJson('/api/produits', [
            'nom' => 'Produit API',
            'prix' => 4999,
        ]);

        $response->assertCreated();
        $response->assertJsonPath('data.nom', 'Produit API');
    }

    public function test_les_utilisateurs_ne_peuvent_pas_supprimer_les_produits_des_autres(): void
    {
        $proprietaire = User::factory()->create();
        $attaquant = User::factory()->create();
        $produit = Product::factory()->create(['id_utilisateur' => $proprietaire->id]);

        $this->actingAs($attaquant)
            ->deleteJson("/api/produits/{$produit->id}")
            ->assertForbidden();
    }
}

Tests d'authentification Sanctum

namespace Tests\Feature\Http\Controllers\Api;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;

class AuthControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_les_utilisateurs_peuvent_s_inscrire(): void
    {
        $response = $this->postJson('/api/inscription', [
            'nom' => 'Utilisateur Test',
            'email' => '[email protected]',
            'mot_de_passe' => 'MotDePasse123!',
            'mot_de_passe_confirmation' => 'MotDePasse123!',
        ]);

        $response->assertCreated();
        $response->assertJsonStructure(['data' => ['utilisateur', 'jeton']]);
    }

    public function test_les_utilisateurs_peuvent_se_connecter(): void
    {
        User::factory()->create([
            'email' => '[email protected]',
            'password' => Hash::make('MotDePasse123!'),
        ]);

        $response = $this->postJson('/api/connexion', [
            'email' => '[email protected]',
            'password' => 'MotDePasse123!',
        ]);

        $response->assertOk();
        $response->assertJsonStructure(['data' => ['jeton']]);
    }

    public function test_les_utilisateurs_ne_peuvent_pas_se_connecter_avec_un_mauvais_mot_de_passe(): void
    {
        User::factory()->create(['email' => '[email protected]']);

        $this->postJson('/api/connexion', [
            'email' => '[email protected]',
            'password' => 'faux',
        ])->assertUnprocessable();
    }

    public function test_le_porteur_du_jeton_authentifie_les_requetes(): void
    {
        $user = User::factory()->create();
        $jeton = $user->createToken('test')->plainTextToken;

        $this->withToken($jeton)
            ->getJson('/api/utilisateur')
            ->assertOk()
            ->assertJsonPath('data.email', $user->email);
    }
}

Mocking et Fakes

Fake HTTP

use Illuminate\Support\Facades\Http;

public function test_il_gerer_un_paiement_reussi(): void
{
    Http::fake([
        'api.stripe.com/*' => Http::response(['id' => 'pi_123', 'statut' => 'réussi'], 200),
    ]);

    $resultat = (new PaymentService())->debiter(2999);
    $this->assertTrue($resultat->succes);
}

public function test_il_gerer_un_echec_de_la_passerelle(): void
{
    Http::fake([
        'api.stripe.com/*' => Http::response(['erreur' => 'carte_refusee'], 402),
    ]);

    $this->expectException(PaymentFailedException::class);
    (new PaymentService())->debiter(2999);
}

public function test_il_renvoie_en_cas_de_timeout(): void
{
    Http::fake([
        'api.stripe.com/*' => Http::sequence()
            ->pushStatus(408)
            ->pushStatus(200),
    ]);

    $this->assertTrue((new PaymentService())->debiter(2999)->succes);
}

Fake Courrier

Mail::fake();

$commande->envoyerConfirmation();

Mail::assertSent(OrderConfirmation::class, function ($mail) use ($commande) {
    return $mail->hasTo($commande->user->email);
});

Fake Notification

Notification::fake();

$user->notify(new WelcomeUser());

Notification::assertSentTo($user, WelcomeUser::class);

Fake File d'attente

Queue::fake();

ProcessImage::dispatch($produit);

Queue::assertPushed(ProcessImage::class, function ($job) use ($produit) {
    return $job->produit->id === $produit->id;
});

Fake Stockage

Storage::fake('public');

$fichier = UploadedFile::fake()->image('photo.jpg', 200, 200);

$response = $this->actingAs($user)->post('/avatar', [
    'avatar' => $fichier,
]);

$response->assertSessionHasNoErrors();
Storage::disk('public')->assertExists('avatars/' . $fichier->hashName());

Fake Événement

Event::fake();

$commande->marquerCommeExpedie();

Event::assertDispatched(OrderShipped::class, function ($event) use ($commande) {
    return $event->commande->id === $commande->id;
});

Tests des commandes Artisan

public function test_il_envoie_les_newsletters(): void
{
    Mail::fake();
    User::factory()->count(5)->create(['abonne' => true]);

    $this->artisan('newsletter:envoyer')
        ->expectsOutput('Envoi de la newsletter à 5 abonnés...')
        ->assertExitCode(0);

    Mail::assertSent(NewsletterMail::class, 5);
}

public function test_il_gerer_l_absence_d_abonnes(): void
{
    $this->artisan('newsletter:envoyer')
        ->expectsOutput('Aucun abonné trouvé.')
        ->assertExitCode(0);
}

Tests d'autorisation

public function test_les_utilisateurs_peuvent_modifier_leurs_propres_articles(): void
{
    $user = User::factory()->create();
    $article = Post::factory()->create(['id_utilisateur' => $user->id]);

    $this->actingAs($user)
        ->put(route('posts.update', $article), ['titre' => 'Mis à jour'])
        ->assertRedirect();
}

public function test_les_utilisateurs_ne_peuvent_pas_modifier_les_articles_des_autres(): void
{
    $article = Post::factory()->create();
    $this->actingAs(User::factory()->create())
        ->put(route('posts.update', $article), ['titre' => 'Piraté'])
        ->assertForbidden();
}

public function test_les_admins_peuvent_supprimer_n_importe_quel_article(): void
{
    $admin = User::factory()->admin()->create();
    $article = Post::factory()->create();

    $this->actingAs($admin)
        ->delete(route('posts.destroy', $article))
        ->assertRedirect();
}

public function test_les_utilisateurs_standard_ne_peuvent_pas_supprimer_les_articles_des_autres(): void
{
    $user = User::factory()->create();
    $article = Post::factory()->create();

    $this->actingAs($user)
        ->delete(route('posts.destroy', $article))
        ->assertForbidden();
}

Tests de validation

public function test_le_champ_email_doit_etre_unique(): void
{
    User::factory()->create(['email' => '[email protected]']);

    $this->postJson('/api/inscription', [
        'nom' => 'Autre Utilisateur',
        'email' => '[email protected]',
        'mot_de_passe' => 'MotDePasse123!',
    ])->assertJsonValidationErrorFor('email');
}

public function test_le_prix_doit_etre_numerique(): void
{
    $this->actingAs(User::factory()->create());

    $this->post(route('products.store'), [
        'nom' => 'Produit',
        'prix' => 'pas un nombre',
    ])->assertSessionHasErrors('prix');
}

Couverture de code

# Générer un rapport de couverture avec Xdebug ou PCOV
php artisan test --coverage

# Générer un rapport HTML détaillé
php artisan test --coverage-html=coverage

# Exiger un pourcentage minimum de couverture
php artisan test --coverage-min=80

Fichier de configuration de couverture PHPUnit

<phpunit>
    <!-- ... -->
    <source>
        <include>
            <directory suffix=".php">app</directory>
        </include>
        <exclude>
            <directory suffix=".php">app/Providers</directory>
            <file>app/Exceptions/Handler.php</file>
        </exclude>
    </source>
</phpunit>

Exemple de test avec Pest

<?php

it('crée un produit', function () {
    $user = User::factory()->create();
    actingAs($user);

    $response = post(route('products.store'), [
        'nom' => 'Produit Pest',
        'description' => 'Description',
        'prix' => 1500,
    ]);

    $response->assertRedirect();
    assertDatabaseHas('products', ['nom' => 'Produit Pest']);
});

it('rejette les champs invalides', function ($champ, $valeur, $regle) {
    $this->actingAs(User::factory()->create());
    $this->post(route('products.store'), [$champ => $valeur])
        ->assertSessionHasErrors($champ);
})->with([
    ['nom', '', 'requis'],
    ['prix', 'abc', 'numérique'],
    ['prix', -5, 'minimum'],
    ['stock', 'ABC', 'entier'],
]);

it('garantit que seul le propriétaire peut supprimer', function () {
    $proprietaire = User::factory()->create();
    $attaquant = User::factory()->create();
    $produit = Product::factory()->create(['id_utilisateur' => $proprietaire->id]);

    actingAs($attaquant)
        ->delete(route('products.destroy', $produit))
        ->assertForbidden();

    actingAs($proprietaire)
        ->delete(route('products.destroy', $produit))
        ->assertRedirect();
});

Pipelines CI (GitHub Actions Example)

nom : Tests Laravel

sur : [push, pull_request]

emplois :
  tests :
    exécutions-sur : ubuntu-latest

    étapes :
    - nom : Checkout
      utilise : actions/checkout@v4

    - nom : Configurer PHP
      utilise : shivammathur/setup-php@v2
      avec :
        php-version : '8.2'
        extensions : mbstring, sqlite, pdo_sqlite
        couverture : pcov

    - nom : Installer les dépendances
      exécution : composer install --prefer-dist --no-progress

    - nom : Environnement de test
      exécution : cp .env.example .env && php artisan key:generate

    - nom : Exécuter les tests
      exécution : php artisan test --coverage --coverage-min=80

Meilleures pratiques

  1. Une assertion par test : chaque test doit vérifier un seul comportement
  2. Utiliser des noms de test descriptifs : test_un_utilisateur_authentifie_peut_creer_un_produit
  3. Suivre le cycle TDD : ROUGE → VERT → RÉUSINAGE
  4. Écrire des tests pour la logique métier : ne pas tester les gets/sets basiques à moins qu'ils n'aient un comportement personnalisé
  5. Utiliser les fakes au lieu de mocks complexes : Http::fake(), Event::fake(), etc.
  6. Garder les tests indépendants : chaque test doit s'exécuter isolément avec RefreshDatabase
  7. Tester les cas limites : valeurs vides, négatives, limites
  8. Ne pas simuler ce que vous ne possédez pas : préférer les fakes fournis par Laravel

Dépannage

ProblèmeSolution
SQLSTATE[HY000]: General error: 1 table already existsSupprimer RefreshDatabase ou utiliser :memory:
Session store not set on requestAjouter extends TestCase et définir SESSION_DRIVER=array
Les tests envoient des emails réelsVérifier MAIL_MAILER=array dans phpunit.xml
The assertStatus method requires exception handlingSupprimer les appels à withoutExceptionHandling() dans setUp
Authentification Sanctum échouant en APIAjouter EnsureFrontendRequestsAreStateful dans Kernel.php pour Sanctum SPA
Tests avec bases SQLite lentsUtiliser RefreshDatabase avec :memory: et minimiser les migrations

Références

Individual skills in this repo

This repo contains 20 individual skills — each has its own dedicated page.

accessibility

Design, implement, and audit inclusive digital products using WCAG 2.2 Level AA. Use when building or auditing UI that must meet WCAG 2.2 Level AA, or when reviewing a change for keyboard, contrast, or screen-reader support.

affaan-m/claude-api

Anthropic Claude API patterns for Python and TypeScript. Covers Messages API, streaming, tool use, vision, extended thinking, batches, prompt caching, and Claude Agent SDK. Use when building applications with the Claude API or Anthropic SDKs.

affaan-m/everything-claude-code

End-to-end marketing campaign planning and execution. Covers audience research, positioning, campaign angle definition, landing page copy, email sequences, social posts, ad copy, short-form video scripts, and content calendars. Use as the orchestration layer for multi-channel product launches. Use when planning or executing a multi-channel product launch, or producing landing page, email, social, or ad copy.

affaan-m/everything-claude-code

Development conventions and patterns for everything-claude-code. JavaScript project with conventional commits.

affaan-m/everything-claude-code-conventions

Development conventions and patterns for everything-claude-code. JavaScript project with conventional commits.

affaan-m/frontend-design

Create distinctive, production-grade frontend interfaces with high design quality. Use when the user asks to build web components, pages, or applications and the visual direction matters as much as the code quality.

affaan-m/gget

gget CLI and Python workflow for quick genomic database queries, sequence lookup, BLAST-style searches, enrichment checks, and reproducible bioinformatics evidence logs.

affaan-m/literature-review

Systematic literature-review workflow for academic, biomedical, technical, and scientific topics, including search planning, source screening, synthesis, citation checks, and evidence logging.

affaan-m/motion-ui

Production-ready UI motion system for React/Next.js. Use when implementing animations, transitions, or motion patterns.

affaan-m/project-guidelines-example

Example project-specific skill template based on a real production application.

affaan-m/pubmed-database

Direct PubMed and NCBI E-utilities search workflows for biomedical literature, MeSH queries, PMID lookup, citation retrieval, and API-backed literature monitoring.

affaan-m/scholar-evaluation

Structured scholarly-work evaluation for papers, proposals, literature reviews, methods sections, evidence quality, citation support, and research-writing feedback.

affaan-m/uspto-database

USPTO patent and trademark data workflow for official record lookup, PatentSearch queries, TSDR checks, assignment data, and reproducible IP research logs.

agent-architecture-audit

Full-stack diagnostic for agent and LLM applications. Audits the 12-layer agent stack for wrapper regression, memory pollution, tool discipline failures, hidden repair loops, and rendering corruption. Produces severity-ranked findings with code-first fixes. Essential for developers building agent applications, autonomous loops, or any LLM-powered feature. Use when an agent or LLM feature misbehaves and the failing layer is unknown, or before shipping an agent stack.

agent-eval

Head-to-head comparison of coding agents (Claude Code, Aider, Codex, etc.) on custom tasks with pass rate, cost, time, and consistency metrics. Use when choosing between coding agents, or when a change to an agent setup needs measured pass rate, cost, and time rather than an impression.

agent-harness-construction

Design and optimize AI agent action spaces, tool definitions, and observation formatting for higher completion rates. Use when defining or revising an agent

agentic-engineering

Operate as an agentic engineer using eval-first execution, decomposition, and cost-aware model routing. Use when planning or executing engineering work that agents will carry out end to end.

agentic-os

Build persistent multi-agent operating systems on Claude Code. Covers kernel architecture, specialist agents, slash commands, file-based memory, scheduled automation, and state management without external databases. Use when building a persistent multi-agent system on Claude Code with its own memory, commands, and scheduling.

agent-introspection-debugging

Structured self-debugging workflow for AI agent failures using capture, diagnosis, contained recovery, and introspection reports. Use when an agent run fails and you need a reproducible diagnosis instead of a retry.

agent-payment-x402

Add x402 payment execution to AI agents with per-task budgets, spending controls, and non-custodial wallets. Supports Base through agentwallet-sdk and X Layer through OKX Payments / OKX Agent Payments Protocol. Use when an agent must pay for something itself and needs per-task budgets, spending controls, and a non-custodial wallet.

Skills associés