Frameworks and agents · Published 2026-10-31 · Updated 2026-10-31 · AgentiSend
Send email with Symfony
A Symfony controller action that posts one invoice notice to POST /emails with PHP streams.
To send email with Symfony, map a POST route to a controller action that posts JSON to POST /emails. The controller guide says an action is a method on a class, mapped with the Route attribute from Symfony\Component\Routing\Attribute\Route, and that the method returns a Response. There is no Symfony package for this API, and this page does not invent one. The method below checks the address, posts with PHP streams, uses an idempotency key derived from the recipient, and returns the message id or the refusal's code and fix. The suite runs the method with php send.php against a local AgentiSend server. It does not boot Symfony.
Install
No package to add for the call itself. The method uses PHP streams. The attribute from the controller guide, pointing at create:
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/send', name: 'app_send', methods: ['POST'])]
public function create(#[MapRequestPayload] SendInput $input): JsonResponse
{
$result = (new InvoiceController())->create([
'email' => $input->email,
'note' => $input->note,
]);
return $this->json($result['body'], $result['status']);
}The controller guide maps the action with that Route attribute, including methods. It maps a JSON body onto a DTO with MapRequestPayload. It returns JSON with $this->json(), which builds a JsonResponse. $this->json() is on the optional AbstractController base class that guide describes. SendInput is your DTO with email and note.
Environment
| Variable | Required | What it is |
|---|---|---|
AGENTISEND_API_KEY | yes | A key with sending_access. |
MAIL_FROM | yes | The From address, on a domain you have verified. |
AGENTISEND_BASE_URL | no | Defaults to https://api.agentisend.com. |
The route
<?php
/**
* Symfony — the action a controller route calls.
*
* There is no Symfony package for this API. The method below is what you put
* on a controller and map with the Route attribute. It posts JSON to
* POST /emails with PHP streams, the same call the action would make. The
* framework is not booted here; the method is the one the route holds.
*
* AGENTISEND_API_KEY, MAIL_FROM, and optionally AGENTISEND_BASE_URL.
* Run: php send.php
*/
/** Enough to refuse an obvious typo here; the API checks the address properly. */
function symfony_address_ok(string $email): bool
{
return preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $email) === 1;
}
final class InvoiceController
{
/**
* POST /send {"email": "ada@example.com", "note": "march"}
*
* @param array{email?: string, note?: string} $input
* @return array{status: int, body: array<string, mixed>}
*/
public function create(array $input): array
{
$email = trim((string) ($input['email'] ?? ''));
$note = (string) ($input['note'] ?? '');
if (!symfony_address_ok($email)) {
return [
'status' => 400,
'body' => ['error' => 'email must be one address, like you@example.com'],
];
}
$from = getenv('MAIL_FROM');
if ($from === false || $from === '') {
throw new RuntimeException('Set MAIL_FROM to an address on a domain you have verified.');
}
$base = getenv('AGENTISEND_BASE_URL');
if ($base === false || $base === '') {
$base = 'https://api.agentisend.com';
}
$key = getenv('AGENTISEND_API_KEY') ?: '';
$payload = json_encode([
'from' => $from,
'to' => $email,
'subject' => 'Your invoice is ready',
'text' => 'Invoice note: ' . $note,
], JSON_THROW_ON_ERROR);
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => implode("\r\n", [
'authorization: Bearer ' . $key,
'content-type: application/json',
'accept: application/json',
// The invoice recipient, not the moment the action ran.
'idempotency-key: invoice/' . $email,
]),
'content' => $payload,
'ignore_errors' => true,
'timeout' => 30,
],
]);
$raw = file_get_contents(rtrim((string) $base, '/') . '/emails', false, $context);
if ($raw === false) {
throw new RuntimeException('POST /emails failed before a response');
}
$status = 0;
if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $match) === 1) {
$status = (int) $match[1];
}
/** @var array<string, mixed> $parsed */
$parsed = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if ($status >= 400) {
$error = is_array($parsed['error'] ?? null) ? $parsed['error'] : [];
return ['status' => $status, 'body' => ['code' => $error['code'] ?? null, 'fix' => $error['fix'] ?? null]];
}
return ['status' => $status, 'body' => ['id' => $parsed['id'] ?? null]];
}
}
/**
* @param array{status: int, body: array<string, mixed>} $result
*/
function symfony_emit(string $step, array $result): void
{
echo json_encode(['step' => $step, 'status' => $result['status']] + $result['body'], JSON_THROW_ON_ERROR), "\n";
}
if (PHP_SAPI === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
$controller = new InvoiceController();
symfony_emit('send', $controller->create(['email' => 'symfony@example.com', 'note' => 'first']));
symfony_emit('retry', $controller->create(['email' => 'symfony@example.com', 'note' => 'first']));
symfony_emit('invalid', $controller->create(['email' => 'not-an-address', 'note' => 'first']));
symfony_emit('refused', $controller->create(['email' => 'symfony@example.com', 'note' => 'second']));
}Try it
php send.phpThe file prints one JSON object per step: the invoice, the same request again, a malformed address answered before any request, and a changed note under the same key.
What comes back when it goes wrong
Every refused request carries code, message, fix and docs_url. The method returns code and fix with the API's status, and the action passes that status to $this->json(). The error catalogue lists every code.