> **The official Symfony bundle for sending Kakao Alimtalk, Brand Message and SMS**

`sendgo/symfony` wraps the [`sendgo/php`](https://github.com/send-go/php) core as a Symfony bundle: it registers `Sendgo\Php\Sendgo` in the container, validates your configuration at compile time, and makes the client autowirable.

Requires PHP 8.2+ and Symfony 6.4 or 7.x.

---

## Install

```bash
composer require sendgo/symfony
```

The core `sendgo/php` comes along as a dependency.

### Register the bundle

With [Symfony Flex](https://symfony.com/doc/current/setup/flex.html) the bundle registers itself. Without Flex, add it by hand:

```php
<?php
// config/bundles.php

return [
    // ...
    Sendgo\Symfony\SendgoBundle::class => ['all' => true],
];
```

---

## Configure

```env
# .env
SENDGO_ACCESS_KEY=your_access_key
SENDGO_SECRET_KEY=your_secret_key
SENDGO_KAKAO_SENDER_KEY=your_kakao_sender_key
SENDGO_SMS_SENDER_KEY=your_sms_sender_key
```

```yaml
# config/packages/sendgo.yaml
sendgo:
    access_key:       '%env(SENDGO_ACCESS_KEY)%'
    secret_key:       '%env(SENDGO_SECRET_KEY)%'
    kakao_sender_key: '%env(SENDGO_KAKAO_SENDER_KEY)%'
    sms_sender_key:   '%env(SENDGO_SMS_SENDER_KEY)%'
    api_version:      'v2'
```

`access_key` and `secret_key` are declared `isRequired()->cannotBeEmpty()`, so a missing key fails **container compilation** — the deploy breaks at cache warm-up rather than at the first send.

Note that `api_version` defaults to **`v2`** in the bundle (the bare PHP core defaults to `v1`).

---

## Inject the client

The bundle registers the service under its FQCN, so constructor autowiring just works — no `services.yaml` entry needed:

```php
<?php
// src/Controller/OrderController.php

namespace App\Controller;

use App\Entity\Order;
use Sendgo\Php\Sendgo;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

class OrderController extends AbstractController
{
    public function __construct(private Sendgo $sendgo) {}

    #[Route('/orders/{id}/confirm', methods: ['POST'])]
    public function confirm(Order $order): JsonResponse
    {
        $this->sendgo->alimtalk->send([
            'templateCode' => 'ORDER_CONFIRM_001',
            'contacts'     => [[
                'contact' => $order->getUser()->getPhone(),
                'var1'    => $order->getNumber(),
            ]],
        ]);

        return $this->json(['success' => true]);
    }
}
```

Every channel is a **property** on the client:

| Property | Channel |
|----------|---------|
| `$sendgo->alimtalk` | Kakao Alimtalk |
| `$sendgo->friendtalk` | Kakao Friendtalk |
| `$sendgo->brandMessage` | Kakao Brand Message (v2 only) |
| `$sendgo->sms` | SMS / LMS / MMS |

A public `sendgo` alias is also registered, so `$container->get('sendgo')` works where autowiring is not available (a legacy service, a console script wired by hand).

---

## Brand Message

> **Friendtalk was discontinued on 2025-12-31.** Since 2026-01-01 Kakao delivers
> Friendtalk requests as Brand Message (free-form) automatically. `friendtalk` still
> works and is still the only path for free-form `FT`/`FI`/`FW` to individual
> recipients — use Brand Message for template-based rich types (`FL`/`FC`/`FM`/`FP`/`FA`),
> non-friend targeting (`N`/`I`) and broadcasts (`F`).
Brand Message is the successor channel to Friendtalk: it reaches recipients who are **not channel friends** (`'targeting' => 'N'`) and can **broadcast to every consenting channel friend** (`'targeting' => 'F'`). Message types map one-to-one with Friendtalk (`FT`→`BT`, `FI`→`BI`, `FW`→`BW`, `FL`→`BL`, `FC`→`BC`, `FM`→`BM`, `FP`→`BP`, `FA`→`BA`) — pass the Friendtalk code and the server converts it.

> v2 only, which is the bundle's default.

```php
<?php
// src/Service/CampaignService.php

namespace App\Service;

use Sendgo\Php\Sendgo;

class CampaignService
{
    public function __construct(private Sendgo $sendgo) {}

    // Single send — targeting M/N/I requires `contacts`
    public function promote(string $phone): array
    {
        return $this->sendgo->brandMessage->send([
            'targeting'          => 'M',
            'messageType'        => 'FL',
            'friendTemplateUuid' => '9cd5460b-6458-4edc-9b11-c26d3013c340',
            'contacts'           => [['contact' => $phone, 'var1' => '29,000 KRW']],
        ]);
    }

    // Broadcast — every consenting channel friend, no recipient list
    public function announce(): array
    {
        return $this->sendgo->brandMessage->broadcast([
            'messageType'        => 'FW',
            'friendTemplateUuid' => '9cd5460b-6458-4edc-9b11-c26d3013c340',
        ]);
    }

    // A broadcast is asynchronous upstream, so poll for progress
    public function status(string $campaignId): array
    {
        return $this->sendgo->brandMessage->campaign($campaignId);
    }

    public function recent(): array
    {
        return $this->sendgo->brandMessage->campaigns(['count' => 10]);
    }
}
```

`broadcast()` is `send()` with `targeting` forced to `F`, so anything you can pass to one you can pass to the other.

---

## Asynchronous sending with Messenger

Sending is an outbound HTTP call. Route it through [Messenger](https://symfony.com/doc/current/messenger.html) so a slow upstream never holds a web request open:

```php
<?php
// src/Message/SendAlimtalk.php

namespace App\Message;

class SendAlimtalk
{
    /** @param array<int, array<string, string>> $contacts */
    public function __construct(
        public readonly string $templateCode,
        public readonly array $contacts,
    ) {}
}
```

```php
<?php
// src/MessageHandler/SendAlimtalkHandler.php

namespace App\MessageHandler;

use App\Message\SendAlimtalk;
use Psr\Log\LoggerInterface;
use Sendgo\Php\Exception\SendgoException;
use Sendgo\Php\Sendgo;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;

#[AsMessageHandler]
class SendAlimtalkHandler
{
    public function __construct(
        private Sendgo $sendgo,
        private LoggerInterface $logger,
    ) {}

    public function __invoke(SendAlimtalk $message): void
    {
        try {
            $this->sendgo->alimtalk->send([
                'templateCode' => $message->templateCode,
                'contacts'     => $message->contacts,
            ]);
        } catch (SendgoException $e) {
            if ($e->getStatusCode() < 500) {
                // A 4xx will not succeed on retry — stop instead of burning the retry budget.
                $this->logger->error('Sendgo {code}: {message}', [
                    'code'    => $e->getErrorCode(),
                    'message' => $e->getMessage(),
                ]);

                throw new UnrecoverableMessageHandlingException($e->getMessage(), previous: $e);
            }

            throw $e;   // 5xx is transient — let Messenger retry
        }
    }
}
```

Throwing `UnrecoverableMessageHandlingException` for 4xx is the part that matters: without it, a bad template code is retried until the transport gives up and the failure looks transient.

Dispatch it from wherever the business event happens:

```php
$bus->dispatch(new SendAlimtalk('ORDER_CONFIRM_001', [
    ['contact' => '01012345678', 'var1' => 'ORD-001'],
]));
```

### Sending after a Doctrine flush

Dispatching inside a transaction means a later rollback still leaves the message delivered. Configure the [Doctrine transaction middleware](https://symfony.com/doc/current/messenger.html#middleware) or dispatch from a `postFlush` listener rather than from the entity's lifecycle callbacks.

---

## Error handling

```php
<?php

use Sendgo\Php\Exception\SendgoException;

try {
    $this->sendgo->alimtalk->send([...]);
} catch (SendgoException $e) {
    $this->logger->error('Sendgo send failed', [
        'status'     => $e->getStatusCode(),
        'error_code' => $e->getErrorCode(),
        'endpoint'   => $e->getEndpoint(),
    ]);

    match ($e->getErrorCode()) {
        'INVALID_ACCESS_KEY',
        'INVALID_SECRET_KEY'    => $this->alertOps('Check the Sendgo API keys'),
        'IP_NOT_ALLOWED'        => $this->alertOps('IP is not allow-listed'),
        'PAYMENT_REQUIRED'      => $this->alertOps('Out of Sendgo credit'),
        'INVALID_TEMPLATE_CODE' => $this->logger->warning('Unknown template'),
        default                 => null,
    };
}
```

Branch on `getErrorCode()`, not on the message text — messages can change, codes are the contract.
`TOKEN_EXPIRED` and `TOKEN_MISMATCH` are handled inside the core SDK: the token is reissued and the request retried once.

---

## Testing

The service is public, so you can replace it in the test container:

```php
<?php
// tests/Controller/OrderControllerTest.php

namespace App\Tests\Controller;

use Sendgo\Php\AlimtalkService;
use Sendgo\Php\Sendgo;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class OrderControllerTest extends WebTestCase
{
    public function testConfirmSendsAlimtalk(): void
    {
        $client = static::createClient();

        $alimtalk = $this->createMock(AlimtalkService::class);
        $alimtalk->expects($this->once())->method('send');

        // The channels are `readonly` properties. Reflection can set them only
        // while they are still uninitialised — which is exactly the state a
        // PHPUnit double is in, because it never runs the real constructor.
        // Calling setValue() on a real, constructed client throws
        // "Cannot modify readonly property".
        $sendgo = $this->createMock(Sendgo::class);
        (new \ReflectionProperty(Sendgo::class, 'alimtalk'))->setValue($sendgo, $alimtalk);

        static::getContainer()->set(Sendgo::class, $sendgo);

        $client->request('POST', '/orders/1/confirm');

        $this->assertResponseIsSuccessful();
    }
}
```

For most suites it is simpler to put your own interface in front of the client — mock `NotificationSenderInterface` and leave `Sendgo` out of the test entirely. Reflection is only needed when you want to assert on the SDK call itself.

---

## Configuration reference

| Key | Required | Default | Description |
|-----|----------|---------|-------------|
| `access_key` | **required** | — | Sendgo access key |
| `secret_key` | **required** | — | Sendgo secret key |
| `kakao_sender_key` | optional | `null` | Kakao sender profile key |
| `sms_sender_key` | optional | `null` | SMS caller ID key |
| `api_version` | optional | `v2` | API version (`v1` \| `v2`) |
| `url` | optional | `https://sendgo.io` | API base URL |

---

## FAQ

**How is this different from `sendgo/php`?**
`sendgo/php` is the framework-agnostic core. `sendgo/symfony` adds bundle registration, a validated configuration tree, container registration and autowiring on top of it. Both send the same requests.

**Does it support Symfony 6.4 and 7?**
Yes — `symfony/config`, `symfony/dependency-injection` and `symfony/http-kernel` are all constrained to `^6.4|^7.0`.

**Can I have more than one client (multi-tenant)?**
The bundle registers a single service. For per-tenant keys, register your own factory service that builds `Sendgo\Php\Sendgo` from the current tenant and inject that instead.

---

## Short URL

Short URLs shrink the links in your message body and count whether they were
actually clicked. SMS is billed by byte, so a shorter link leaves more room for copy.

> v2 only.

Shortening the same target URL again **returns the existing link**. Pass `forceNew`
to mint a new code when you want per-campaign reaction figures kept separate.

`deactivate` does not delete the link — it only stops the redirect. Use it when a link
in an already-sent message has to be killed; the accumulated stats stay, and visitors
to a stopped link get `410 Gone`.

```php
// Accessed as a property on the autowired Sendgo\Php\Sendgo.
$short = $this->sendgo->shortUrl->create([
    'targetUrl' => 'https://example.com/promotions/summer-sale',
    'title'     => 'Summer sale landing',
]);

$code = $short['data']['code'];
$stats = $this->sendgo->shortUrl->stats($code);
```

`stats` returns a daily series (`daily`) plus breakdowns by device (`byDevice`), referrer (`byReferer`) and country (`byCountry`). The daily series is read from a pre-aggregated table, so response time stays flat no matter how many clicks accumulate.

---

## Package information

- **Package**: `sendgo/symfony` (Packagist)
- **Repository**: [send-go/symfony](https://github.com/send-go/symfony)
- **Registry**: https://packagist.org/packages/sendgo/symfony
- **License**: MIT

### Getting your API keys

Sign in to Sendgo and open **API/SDK → API integration** to issue an access key and secret key.
Register a Kakao sender profile under **Kakao channel** to get your `kakao_sender_key`, and a caller ID under **Sender numbers** for SMS.