From Blockchain to Database: Synchronizing Soroban with PHP

Published: (December 7, 2025 at 04:49 AM EST)
3 min read
Source: Dev.to

Source: Dev.to

Introduction

One of the most interesting challenges when working with blockchain is maintaining precise synchronization between on‑chain transactions and our off‑chain system. In this article, I’ll share how I solved this challenge in Equillar: converting the result of a Soroban smart contract call into a PHP Doctrine entity, achieving an exact replica of the blockchain state in our database.

The Journey of a Transaction

Imagine a user wants to create an investment. From the moment they click the button until the data is perfectly stored in our database, a chain of events takes place. Let’s follow that flow step by step.

1. The Entry Point: The Controller

Everything begins in the createUserContract endpoint:

#[Route('/create-user-investment', name: 'post_create_user_contract_investment', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function createUserContract(
    #[MapRequestPayload] CreateUserContractDtoInput $createUserContractDtoInput, 
    CreateUserContractService $createUserContractService
): JsonResponse
{
    $user = $this->getUser();
    return $this->json($createUserContractService->createUserContract($createUserContractDtoInput, $user));
}

Simple and straightforward: we receive the user’s data, validate they’re authenticated, and delegate the business logic to the corresponding service.

2. Setting the Stage: CreateUserContractService

The CreateUserContractService prepares all pieces before executing the blockchain transaction.

public function createUserContract(CreateUserContractDtoInput $createUserContractDtoInput, User $user): UserContractDtoOutput
{
    // 1. Get the contract from the blockchain by its address
    $contract = $this->contractStorage->getContractByAddress(
        StrKey::decodeContractIdHex($createUserContractDtoInput->contractAddress)
    );

    // 2. Verify or create the user's wallet
    $userWallet = $this->userWalletStorage->getWalletByAddress($createUserContractDtoInput->fromAddress);
    if (!$userWallet) {
        $userWallet = $this->userWalletEntityTransformer->fromUserAndAddressToUserWalletEntity(
            $user,
            $createUserContractDtoInput->fromAddress
        );
        $this->persistor->persist($userWallet);
    }

    // 3. Create the UserContract entity (still without smart contract data)
    $userContract = $this->userContractEntityTransformer->fromCreateUserContractInvestmentDtoToEntity(
        $createUserContractDtoInput,
        $contract,
        $userWallet
    );
    $this->persistor->persist($userContract);
    $this->persistor->flush();

    // 4. (This is the blockchain part) – Process the blockchain transaction
    $this->processUserContractService->processUserContractTransaction($userContract);

    return $this->userContractEntityTransformer->fromEntityToOutputDto($userContract);
}

At this point we have a record in the database, but it still lacks the real blockchain data.

3. The Smart Contract Call: ProcessUserContractService

Here we actually connect with the Stellar/Soroban blockchain.

public function processUserContractTransaction(UserContract $userContract): void
{
    $contractTransaction = null;

    try {
        // 1. Wait for the transaction to be confirmed on the blockchain
        $transactionResponse = $this->processTransactionService->waitForTransaction($userContract->getHash());

        // 2. Use Soneso / PHP Stellar SDK to transform XDR transaction result to PHP types
        $trxResult = $this->scContractResultBuilder->getResultDataFromTransactionResponse($transactionResponse);

        // 3. Map the result to our entity
        $this->userInvestmentTrxResultMapper->mapToEntity($trxResult, $userContract);

        // 4. Save the successful transaction
        $contractTransaction = $this->contractTransactionEntityTransformer->fromSuccessfulTransaction(
            $userContract->getContract()->getAddress(),
            ContractNames::INVESTMENT->value,
            ContractFunctions::invest->name,
            $trxResult,
            $transactionResponse->getTxHash(),
            $transactionResponse->getCreatedAt()
        );

        // 5. Dispatch an event to update the contract balance
        $this->bus->dispatch(new CheckContractBalanceMessage(
            $userContract->getContract()->getId(),
            $transactionResponse->getLedger()
        ));

    } catch (GetTransactionException $ex) {
        // If something goes wrong, log the error
        $userContract->setStatus($ex->getStatus());
        $contractTransaction = $this->contractTransactionEntityTransformer->fromFailedTransaction(
            $userContract->getContract()->getAddress(),
            ContractNames::INVESTMENT->value,
            ContractFunctions::invest->name,
            $ex
        );
    } finally {
        // Always persist the final state
        $this->persistor->persistAndFlush([$userContract, $contractTransaction]);
    }
}

4. Waiting for Confirmation: waitForTransaction

The blockchain isn’t instantaneous. When we send a transaction, it must be included in a ledger and confirmed. The waitForTransaction method implements a polling system.

public function waitForTransaction(string $hash, int $maxIterations = 10, ?int $microseconds = null): GetTransactionResponse
{
    $counter = 0;
    do {
        // Wait a moment between each check
        ($microseconds > 0) ? usleep($microseconds) : sleep(1);

        // Query the transaction status
        $transactionResponse = $this->server->getTransaction($hash);
        $status = $transactionResponse->status;
        ++$counter;

    } while ($counter getResultValue();
    return $this->getValueFromXdrResult($xdrResult, $transactionResponse->getTxHash());
}
private function getValueFromXdrResult(XdrSCVal $xdrResult, string $hash): mixed
{
    return match ($xdrResult->type->value) {
        XdrSCValType::SCV_VOID   => null,
        XdrSCValType::SCV_BOOL   => $xdrResult->getB(),
        XdrSCValType::SCV_ERROR  => $this->processFunctionCallError($xdrResult->getError(), $hash),
        XdrSCValType::SCV_I128   => $xdrResult->getI128(),
        XdrSCValType::SCV_MAP    => $this->generateForMap($xdrResult->getMap()), // This is the key!
        XdrSCValType::SCV_U32    => $xdrResult->getU32(),
        XdrSCValType::SCV_STRING => $xdrResult->getStr(),
        default                  => $xdrResult->encode(),
    };
}

The SDK’s XdrSCVal class represents Soroban smart contract values, allowing us to map them directly to native PHP types and store them in our Doctrine entities.

Back to Blog

Related posts

Read more »