La intersección de Web3 y los frameworks web tradicionales es donde comienza la utilidad del mundo real. Aunque los ciclos de exageración van y vienen, la utilidad de los Non-fungible tokens (NFT) para verificar la propiedad — específicamente en la venta de entradas para eventos — sigue siendo un caso de uso sólido.
En este artículo, construiremos la columna vertebral de un Sistema Descentralizado de Venta de Entradas para Eventos usando Symfony 7.4 y PHP 8.3. Iremos más allá de los tutoriales básicos e implementaremos una arquitectura de nivel de producción que maneja la naturaleza asíncrona de las transacciones blockchain usando el componente Symfony Messenger.
Un enfoque "Senior" reconoce que PHP no es un proceso de ejecución prolongada como Node.js. Por lo tanto, no escuchamos eventos blockchain en tiempo real dentro de un controlador. En su lugar, usamos un enfoque híbrido:
Muchas librerías PHP Web3 están abandonadas o mal tipadas. Aunque web3p/web3.php es la más famosa, depender estrictamente de ella puede ser arriesgado debido a brechas de mantenimiento.
Para esta guía, usaremos web3p/web3.php (versión ^0.3) para la codificación ABI, pero aprovecharemos el HttpClient nativo de Symfony para el transporte JSON-RPC real. Esto nos da control total sobre timeouts, reintentos y registro — crítico para aplicaciones de producción.
Primero, instalemos las dependencias. Necesitamos el runtime de Symfony, el cliente HTTP y la librería Web3.
composer create-project symfony/skeleton:"7.4.*" decentralized-ticketing cd decentralized-ticketing composer require symfony/http-client symfony/messenger symfony/uid web3p/web3.php
Asegúrate de que tu composer.json refleje la estabilidad:
{ "require": { "php": ">=8.3", "symfony/http-client": "7.4.*", "symfony/messenger": "7.4.*", "symfony/uid": "7.4.*", "web3p/web3.php": "^0.3.0" } }
Necesitamos un servicio robusto para comunicarnos con la blockchain. Crearemos un EthereumService que envuelva las llamadas JSON-RPC.
//src/Service/Web3/EthereumService.php namespace App\Service\Web3; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Web3\Utils; class EthereumService { private const JSON_RPC_VERSION = '2.0'; public function __construct( private HttpClientInterface $client, #[Autowire(env: 'BLOCKCHAIN_RPC_URL')] private string $rpcUrl, #[Autowire(env: 'SMART_CONTRACT_ADDRESS')] private string $contractAddress, #[Autowire(env: 'WALLET_PRIVATE_KEY')] private string $privateKey ) {} /** * Reads the owner of a specific Ticket ID (ERC-721 ownerOf). */ public function getTicketOwner(int $tokenId): ?string { // Function signature for ownerOf(uint256) is 0x6352211e // We pad the tokenId to 64 chars (32 bytes) $data = '0x6352211e' . str_pad(Utils::toHex($tokenId, true), 64, '0', STR_PAD_LEFT); $response = $this->callRpc('eth_call', [ [ 'to' => $this->contractAddress, 'data' => $data ], 'latest' ]); if (empty($response['result']) || $response['result'] === '0x') { return null; } // Decode the address (last 40 chars of the 64-char result) return '0x' . substr($response['result'], -40); } /** * Sends a raw JSON-RPC request using Symfony HttpClient. * This offers better observability than standard libraries. */ private function callRpc(string $method, array $params): array { $response = $this->client->request('POST', $this->rpcUrl, [ 'json' => [ 'jsonrpc' => self::JSON_RPC_VERSION, 'method' => $method, 'params' => $params, 'id' => random_int(1, 9999) ] ]); $data = $response->toArray(); if (isset($data['error'])) { throw new \RuntimeException('RPC Error: ' . $data['error']['message']); } return $data; } }
Ejecuta una prueba local accediendo a getTicketOwner con un ID acuñado conocido. Si obtienes una dirección 0x, tu conexión RPC está funcionando.
Las transacciones blockchain son lentas (15s a minutos). Nunca hagas que un usuario espere por una confirmación de bloque en una solicitud del navegador. Usaremos Symfony Messenger para manejar esto en segundo plano.
//src/Message/MintTicketMessage.php: namespace App\Message; use Symfony\Component\Uid\Uuid; readonly class MintTicketMessage { public function __construct( public Uuid $ticketId, public string $userWalletAddress, public string $metadataUri ) {} }
Aquí es donde ocurre la magia. Usaremos el helper de la librería web3p/web3.php para firmar una transacción localmente.
Nota: En un entorno de alta seguridad, usarías un Servicio de Gestión de Claves (KMS) o un enclave de firma separado. Para este artículo, firmamos localmente.
//src/MessageHandler/MintTicketHandler.php namespace App\MessageHandler; use App\Message\MintTicketMessage; use App\Service\Web3\EthereumService; use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Web3\Contract; use Web3\Providers\HttpProvider; use Web3\RequestManagers\HttpRequestManager; use Web3p\EthereumTx\Transaction; #[AsMessageHandler] class MintTicketHandler { public function __construct( private EthereumService $ethereumService, // Our custom service private LoggerInterface $logger, #[Autowire(env: 'BLOCKCHAIN_RPC_URL')] private string $rpcUrl, #[Autowire(env: 'WALLET_PRIVATE_KEY')] private string $privateKey, #[Autowire(env: 'SMART_CONTRACT_ADDRESS')] private string $contractAddress ) {} public function __invoke(MintTicketMessage $message): void { $this->logger->info("Starting mint process for Ticket {$message->ticketId}"); // 1. Prepare Transaction Data (mintTo function) // detailed implementation of raw transaction signing usually goes here. // For brevity, we simulate the logic flow: try { // Logic to get current nonce and gas price via EthereumService // $nonce = ... // $gasPrice = ... // Sign transaction offline to prevent key exposure over network // $tx = new Transaction([...]); // $signedTx = '0x' . $tx->sign($this->privateKey); // Broadcast // $txHash = $this->ethereumService->sendRawTransaction($signedTx); // In a real app, you would save $txHash to the database entity here $this->logger->info("Mint transaction broadcast successfully."); } catch (\Throwable $e) { $this->logger->error("Minting failed: " . $e->getMessage()); // Symfony Messenger will automatically retry based on config throw $e; } } }
El controlador permanece ligero. Acepta la solicitud, valida la entrada, crea una entidad de ticket "Pendiente" en tu base de datos (omitida por brevedad) y despacha el mensaje.
//src/Controller/TicketController.php: namespace App\Controller; use App\Message\MintTicketMessage; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Uid\Uuid; #[Route('/api/v1/tickets')] class TicketController extends AbstractController { #[Route('/mint', methods: ['POST'])] public function mint(Request $request, MessageBusInterface $bus): JsonResponse { $payload = $request->getPayload(); $walletAddress = $payload->get('wallet_address'); // 1. Basic Validation if (!$walletAddress || !str_starts_with($walletAddress, '0x')) { return $this->json(['error' => 'Invalid wallet address'], 400); } // 2. Generate Internal ID $ticketId = Uuid::v7(); // 3. Dispatch Message (Fire and Forget) $bus->dispatch(new MintTicketMessage( $ticketId, $walletAddress, 'https://api.myapp.com/metadata/' . $ticketId->toRfc4122() )); // 4. Respond immediately return $this->json([ 'status' => 'processing', 'ticket_id' => $ticketId->toRfc4122(), 'message' => 'Minting request queued. Check status later.' ], 202); } }
Siguiendo el estilo de Symfony 7.4, usamos tipado estricto y atributos. Asegúrate de que tu messenger.yaml esté configurado para transporte asíncrono.
#config/packages/messenger.yaml: framework: messenger: transports: async: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' retry_strategy: max_retries: 3 delay: 1000 multiplier: 2 routing: 'App\Message\MintTicketMessage': async
Para verificar que esta implementación funciona sin implementar en Mainnet:
Nodo Local: Ejecuta una blockchain local usando Hardhat o Anvil (Foundry).
npx hardhat node
Entorno: Configura tu .env.local para apuntar a localhost.
BLOCKCHAIN_RPC_URL="http://127.0.0.1:8545" WALLET_PRIVATE_KEY="<one of the test keys provided by hardhat>" SMART_CONTRACT_ADDRESS="<deployed contract address>" MESSENGER_TRANSPORT_DSN="doctrine://default"
Consumir: Inicia el worker.
php bin/console messenger:consume async -vv
Solicitud:
curl -X POST https://localhost:8000/api/v1/tickets/mint \ -H "Content-Type: application/json" \ -d '{"wallet_address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"}'
Deberías ver al worker procesar el mensaje y, si implementaste completamente la lógica de firma de transacción sin procesar, un hash de transacción aparecerá en tu consola Hardhat.
Construir aplicaciones Web3 en PHP requiere un cambio de mentalidad. No solo estás construyendo una aplicación CRUD; estás construyendo un orquestador para estado descentralizado.
Al usar Symfony 7.4, aprovechamos:
Esta arquitectura escala. Ya sea que estés vendiendo 10 tickets o 10 000, la cola de mensajes actúa como un buffer, asegurando que los nonces de tus transacciones no colisionen y tu servidor no se cuelgue.
Integrar blockchain requiere precisión. Si necesitas ayuda auditando las interacciones de tu Smart Contract o escalando tus consumidores de mensajes de Symfony, pongámonos en contacto.
\
