매뉴얼 목록
MUBLO MANUAL

mublo 확장 개발 가이드

플러그인·패키지로 mublo를 확장하는 초보 개발자용 안내입니다. 예제를 따라 만들며 익힙니다.

확장 만들기 기초

확장으로 코어를 건드리지 않고 기능을 더합니다. 이 가이드는 예제를 따라 만들며 익히는 방식입니다.

PHP 를 조금 다뤄 봤다면 충분합니다. 각 장은 앞 장에서 만든 확장에 한 조각씩 붙여 갑니다.

가장 작은 확장

폴더 하나에 manifest.json 과 Provider 하나면 확장이 됩니다.

plugins/Hello/
  manifest.json      확장 정보
  HelloProvider.php  진입점
{
  "name": "Hello",
  "label": "인사",
  "version": "1.0.0",
  "type": "plugin",
  "requires": { "core": ">=1.0.0" }
}
<?php
namespace Mublo\Plugin\Hello;

use Mublo\Core\Extension\ExtensionProviderInterface;
use Mublo\Core\Container\DependencyContainer;
use Mublo\Core\Context\Context;

class HelloProvider implements ExtensionProviderInterface
{
    public function register(DependencyContainer $container): void {}
    public function boot(DependencyContainer $container, Context $context): void {}
}

관리자의 확장 관리에서 켜면 끝입니다. 다음 장부터 라우트·DB·블록·이벤트를 하나씩 붙입니다.

확장 구조

확장은 플러그인(plugins/)과 패키지(packages/)로 나뉩니다. 구조는 같습니다.

표준 구성

plugins/Manual/
  manifest.json        확장 정보(이름·버전·요구사항)
  ManualProvider.php   진입점
  routes.php           라우트
  Controller/          컨트롤러
  Service/             로직
  Repository/          DB 접근
  views/               화면
  database/migrations/ DB 마이그레이션

manifest.json 과 Provider 만 필수입니다. 나머지 폴더는 관례입니다.

Provider — register 먼저, boot 나중

register() 는 서비스를 컨테이너에 등록만 하고, 실제 연결(이벤트·계약)은 boot() 에서 합니다.

public function register(DependencyContainer $container): void
{
    $container->singleton(ManualService::class, function ($c) {
        return new ManualService($c->get(ManualRepository::class));
    });
}

public function boot(DependencyContainer $container, Context $context): void
{
    $dispatcher = $container->get(EventDispatcher::class);
    $dispatcher->addSubscriber(new AdminMenuSubscriber());
}
register 시점엔 다른 확장이 아직 준비 전일 수 있습니다. 남을 참조하거나 이벤트에 끼어드는 일은 모두 boot 에서 하세요.
라우트·컨트롤러·뷰

routes.php 가 URL 을 컨트롤러 메서드에 연결합니다. 플러그인 접두사(/manual)는 자동으로 붙습니다.

use Mublo\Core\App\PrefixedRouteCollector;

return function (PrefixedRouteCollector $r): void {
    // GET /manual
    $r->addRoute('GET', '', [
        'controller' => ManualController::class,
        'method'     => 'index',
    ]);

    // GET /manual/{bookSlug}
    $r->addRoute('GET', '/{bookSlug:[a-z0-9\-]+}', [
        'controller' => ManualController::class,
        'method'     => 'view',
    ]);
};

컨트롤러

메서드는 (array $params, Context $context) 를 받아 ViewResponse 를 반환합니다. 의존성은 생성자로 주입받습니다.

class ManualController
{
    public function __construct(
        private ManualService $manualService,
    ) {}

    public function view(array $params, Context $context): ViewResponse
    {
        $domainId = $context->getDomainId() ?? 1;
        $book = $this->manualService->getBookBySlug($domainId, $params['bookSlug'] ?? '');

        return ViewResponse::absoluteView(self::SKIN_PATH . 'View')
            ->withData(['book' => $book]);
    }
}
URL 파라미터({bookSlug})는 $params 로 들어옵니다. 관리자용 라우트에는 'middleware' => [AdminMiddleware::class] 를 더하세요.
마이그레이션 작성

확장의 테이블은 database/migrations/ 의 SQL 파일로 만듭니다. 파일명 앞 번호 순서대로 한 번씩 실행됩니다.

-- 001_create_hello.sql
CREATE TABLE IF NOT EXISTS `hello_messages` (
  `id`        BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `domain_id` BIGINT UNSIGNED NOT NULL,
  `message`   VARCHAR(200) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

컬럼 추가는 문장을 나눠서

기존 테이블에 컬럼을 더할 때는 ALTER 문을 하나씩 나눕니다. 도중에 실패해도 이미 들어간 컬럼은 건너뛰고 나머지를 이어서 넣을 수 있습니다.

ALTER TABLE `hello_messages`
  ADD COLUMN `pinned` TINYINT(1) NOT NULL DEFAULT 0 AFTER `message`;

실행은 Provider 의 설치 단계에서 러너에 맡깁니다.

$runner = $container->get(MigrationRunner::class);
$runner->run('plugin', 'Hello', MUBLO_PLUGIN_PATH . '/Hello/database/migrations');
이미 있는 컬럼을 다시 추가하면 "중복" 오류만 조용히 건너뛰고, 진짜 오류는 실패로 남깁니다. 특정 데이터베이스에만 있는 문법 대신 공통 문법을 쓰면 이식성도 좋아집니다.

코어와 안전하게 연결

확장이 코어의 내부 클래스를 직접 import 하면, 코어가 바뀔 때마다 확장이 깨집니다.

이 장은 그 결합을 피하는 네 가지 도구를 예제로 다룹니다 — 계약(Contract), DI 규칙, 블록 타입, 이벤트입니다.

원칙 하나만 기억하세요. 코어에는 인터페이스(계약)로만 의존하고, 구현체는 레지스트리에서 받아 씁니다. 나머지는 이 원칙의 적용일 뿐입니다.
Contract로 의존하기

코어 기능은 Mublo\Contract\ 아래 인터페이스(계약)로 노출됩니다. 확장은 구현체가 아니라 이 계약에 의존합니다.

1:1 — 구현이 하나뿐일 때

// 등록 (Provider boot)
$registry->bind(ManualQueryInterface::class, $manualService);

// 사용
$manual = $registry->resolve(ManualQueryInterface::class);

1:N — 후보가 여럿일 때

결제·소셜로그인처럼 구현이 여러 개면 키로 등록하고 골라 씁니다.

$registry->register(PaymentInterface::class, 'tosspay', $tossPay, [
    'label' => '토스페이',
]);

$pay = $registry->get(PaymentInterface::class, 'tosspay');
$all = $registry->all(PaymentInterface::class); // 등록된 전체
register() 의 meta(label 등)로, 관리자 목록에서 구현체를 생성하지 않고도 이름을 뿌릴 수 있습니다. 같은 키를 두 번 등록하면 예외가 납니다.

안정 API 게이트

"내부 클래스에 손대지 말라"는 약속을 도구가 강제합니다.

php tools/check-extension-api.php

확장이 코어의 비계약(내부) 심볼을 import 하면 이 검사가 실패합니다. PR 전에 꼭 돌리세요.

DI 규칙

의존성은 생성자로만 받습니다. 클래스 안에서 컨테이너를 뒤지거나 new 로 직접 만들지 않습니다.

권장 — 생성자 주입

class ManualService
{
    public function __construct(
        private ManualRepository $repo,
    ) {}
}

// Provider 에서 배선
$container->singleton(ManualService::class, function ($c) {
    return new ManualService($c->get(ManualRepository::class));
});

금지 — 서비스 로케이터 · new 폴백

// ✗ 컨테이너를 클래스 안에서 뒤짐
$repo = $container->get(ManualRepository::class);

// ✗ 의존을 직접 생성 (교체·테스트 불가)
public function __construct(?CacheInterface $cache = null)
{
    $this->cache = $cache ?? new SimpleCache();
}
이 규칙은 도구가 강제합니다: php tools/check-di-violations.php. 위반이 있으면 실패하니 PR 전에 확인하세요.
블록 콘텐츠 타입 만들기

블록에 새 콘텐츠 타입(예: 지도)을 더하려면 RendererInterface 하나를 구현하고 등록합니다.

1. 렌더러 구현

use Mublo\Core\Block\Renderer\RendererInterface;
use Mublo\Entity\Block\BlockColumn;

class MapRenderer implements RendererInterface
{
    public function render(BlockColumn $column): string
    {
        $addr = htmlspecialchars($column->getContentConfigValue('address', ''));
        return '<div class="map" data-addr="' . $addr . '"></div>';
    }
}

2. 등록 (Provider boot)

use Mublo\Core\Block\BlockRegistry;
use Mublo\Enum\Block\BlockContentKind;

BlockRegistry::registerContentType(
    type: 'map',
    kind: BlockContentKind::PLUGIN->value,
    title: '지도',
    rendererClass: MapRenderer::class,
);
블록 타입 독트린: 전용 블록은 동적 데이터일 때만 만드세요. 배너·문구 배치 같은 단순 표현은 자유 HTML 블록 + 킷으로 충분합니다. 출력은 반드시 htmlspecialchars 로 이스케이프하세요.
이벤트 구독·발행

이벤트로 코어·다른 확장의 흐름에 끼어듭니다. 구독은 EventSubscriberInterface 로 선언합니다.

use Mublo\Core\Event\EventSubscriberInterface;
use Mublo\Core\Event\Tracking\PageViewedEvent;

class VisitLogSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [PageViewedEvent::class => 'onPageViewed'];
    }

    public function onPageViewed(PageViewedEvent $event): void
    {
        // 방문 집계 등
    }
}

// Provider boot
$dispatcher->addSubscriber(new VisitLogSubscriber());

검증(veto) 이벤트

일부 이벤트는 진행을 막을 수 있습니다. 에러를 담으면 코어가 처리를 중단합니다.

public function onValidating(MemberRegisterValidatingEvent $event): void
{
    if ($blocked) {
        $event->addError('허용되지 않은 가입입니다.');
    }
}
리스너에서 난 오류는 기본적으로 로그만 남기고 넘어갑니다(한 확장이 다른 확장을 죽이지 않도록). 실패를 꼭 전파해야 하면 FailFastEventInterface 를 구현한 이벤트를 쓰세요.
실전: 알림·푸시 보내기

확장에서 회원에게 알림을 보내는 방법입니다. 세 갈래(내부 알림·푸시·외부 발송)가 있고, 필요한 것만 골라 씁니다.

내부 알림 (종 아이콘) — 항상 사용 가능

코어가 늘 제공하므로 MemberNotificationPublisherInterface 를 생성자로 주입받아 씁니다.

use Mublo\Contract\Notification\MemberNotificationPublisherInterface;
use Mublo\Contract\Notification\MemberNotification;

class OrderService
{
    public function __construct(
        private MemberNotificationPublisherInterface $notifier,
    ) {}

    public function notifyShipped(int $domainId, int $memberId, int $orderId): void
    {
        $this->notifier->publish(new MemberNotification(
            domainId: $domainId,
            memberId: $memberId,
            type: 'order_shipped',
            title: '상품이 발송되었습니다',
            body: '주문하신 상품이 오늘 출고됐어요.',
            targetUrl: '/mypage/orders/' . $orderId,
            source: 'plugin',
            deduplicationKey: 'ship:' . $orderId,
        ));
    }
}
deduplicationKey 를 주면 같은 키로는 한 번만 저장돼, 이벤트가 두 번 돌아도 알림이 겹치지 않습니다.

푸시 (앱·브라우저) — 있을 때만

푸시 전송 기능은 별도 확장이 제공합니다. 설치돼 있지 않을 수 있으니, 계약이 있는지 확인하고 씁니다.

use Mublo\Core\Registry\ContractRegistry;
use Mublo\Contract\Fcm\FcmMessageServiceInterface;

// $registry 는 ContractRegistry (생성자로 주입)
if ($registry->has(FcmMessageServiceInterface::class)) {
    $push = $registry->resolve(FcmMessageServiceInterface::class);
    $push->dispatchToMember(
        domainId: $domainId,
        memberId: $memberId,
        installationType: null,        // null = 웹·안드로이드·iOS 전체
        action: 'order_shipped',
        payload: ['orderId' => $orderId],
    );
}
반환값 Result['sent' => N, 'failed' => N] 이 담깁니다. 푸시가 필수가 아니라면 has() 로 감싸 없어도 동작하게 하세요.

외부 발송 (문자·이메일) — 있을 때만

use Mublo\Contract\Notification\NotificationGatewayInterface;

if ($registry->has(NotificationGatewayInterface::class)) {
    $gateway = $registry->resolve(NotificationGatewayInterface::class);
    $gateway->send(
        channel: 'sms',
        templateCode: 'order_shipped',
        recipient: $phone,
        fieldValues: ['orderer_name' => $name],
    );
}

내가 전송 백엔드를 제공하려면

반대로 실제 전송을 담당하는 확장을 만든다면, 계약을 구현하고 등록합니다.

class MyPushService implements FcmMessageServiceInterface
{
    // dispatchToInstallation() / dispatchToMember() / dispatchToTopic() 구현
}

// Provider boot
$registry->bind(FcmMessageServiceInterface::class, new MyPushService());
이제 다른 확장이 has() 로 확인하면 참이 되고, 위 소비자 코드가 그대로 동작합니다. 계약이 소비자와 제공자를 느슨하게 이어 줍니다.