How to Send OTP with Symfony (2026)
Symfony OTP tutorial using StartMessaging. Uses HttpClient, ParameterBag for secrets, Form component for validation, and Session for storing the request ID.
Symfony’s service-container model and HttpClient make OTP integration straightforward. This tutorial uses StartMessaging.
Setup
symfony new otp-symfony --webapp
cd otp-symfony
composer require symfony/http-client
Service Configuration
# .env
SM_API_KEY=sm_live_xxxxxxxxxxxxxxxx
SM_BASE_URL=https://api.startmessaging.com
# config/services.yaml
services:
App\\Service\\StartMessagingService:
arguments:
$apiKey: '%env(SM_API_KEY)%'
$baseUrl: '%env(SM_BASE_URL)%'
StartMessagingService
<?php
// src/Service/StartMessagingService.php
namespace App\\Service;
use Symfony\\Contracts\\HttpClient\\HttpClientInterface;
use Symfony\\Component\\Uid\\Uuid;
class StartMessagingService
{
public function __construct(
private HttpClientInterface $client,
private string $apiKey,
private string $baseUrl,
) {}
public function sendOtp(string $phoneNumber): array
{
$r = $this->client->request('POST', $this->baseUrl.'/otp/send', [
'headers' => ['X-API-Key' => $this->apiKey, 'Content-Type' => 'application/json'],
'json' => ['phoneNumber' => $phoneNumber, 'idempotencyKey' => Uuid::v4()->toRfc4122()],
'timeout' => 10,
]);
return $r->toArray()['data'];
}
public function verifyOtp(string $requestId, string $otpCode): bool
{
$r = $this->client->request('POST', $this->baseUrl.'/otp/verify', [
'headers' => ['X-API-Key' => $this->apiKey, 'Content-Type' => 'application/json'],
'json' => ['requestId' => $requestId, 'otpCode' => $otpCode],
]);
return $r->getStatusCode() === 200;
}
}
Auth Controller
<?php
// src/Controller/AuthController.php
namespace App\\Controller;
use App\\Service\\StartMessagingService;
use Symfony\\Bundle\\FrameworkBundle\\Controller\\AbstractController;
use Symfony\\Component\\HttpFoundation\\Request;
use Symfony\\Component\\HttpFoundation\\Response;
use Symfony\\Component\\Routing\\Attribute\\Route;
class AuthController extends AbstractController
{
#[Route('/auth/send-otp', methods: ['POST'])]
public function send(Request $request, StartMessagingService $sm): Response
{
$phone = $request->getPayload()->getString('phoneNumber');
$data = $sm->sendOtp($phone);
$request->getSession()->set('otp_req', $data['requestId']);
return $this->json(['expiresAt' => $data['expiresAt']]);
}
#[Route('/auth/verify-otp', methods: ['POST'])]
public function verify(Request $request, StartMessagingService $sm): Response
{
$code = $request->getPayload()->getString('otpCode');
$rid = $request->getSession()->get('otp_req');
if (!$rid) return $this->json(['error' => 'no active otp'], 400);
$ok = $sm->verifyOtp($rid, $code);
if (!$ok) return $this->json(['error' => 'invalid'], 401);
$request->getSession()->remove('otp_req');
$request->getSession()->set('userPhone', 'verified');
return $this->json(['verified' => true]);
}
}
Form Types
Use Symfony\Component\Validator\Constraints to enforce
E.164 phone shape and 4–8 digit OTP shape on incoming requests.
FAQ
Same flow in PHP/Laravel? See our Laravel guide.
StartMessaging Team
StartMessaging Team