Back to blog
laravel outlook office-js sanctum microsoft-graph webhooks modular-architecture

Building an Outlook Ribbon Add-in on Top of a Laravel Monolith (and Everything That Went Sideways)

The Starting Point

I have a Laravel 12 platform — modular architecture using nwidart/laravel-modules, about 21 modules at this point — that already handles support cases. Cases come in via email (polled from a shared mailbox via Microsoft Graph), get threaded into conversations, and staff manage them through a web UI. The email integration was solid: an InboundEmailProcessor that handles deduplication by Graph message ID, threads replies by In-Reply-To headers or case number in the subject line, saves attachments, and even reopens resolved cases when a new reply lands.

The ask was: give users a button in Outlook's ribbon that opens a task pane, reads the current email, and lets them create a case without leaving their inbox. Then, when someone replies to that email thread, auto-sync the reply into the case. Two moving parts: (1) an Outlook Web Add-in, and (2) Graph API webhook subscriptions to watch conversation threads.

Simple enough on paper. The fun started when I realised the platform had zero API infrastructure.

What Actually Happened

No API Layer At All

The first surprise was that bootstrap/app.php didn't even register an api.php routes file. The entire app was web-only — session auth, CSRF protection, the lot. There was no routes/api.php. Sanctum wasn't installed. There was no mechanism for anything to authenticate via a Bearer token.

So before I could build an Outlook add-in, I needed to build an API layer. I ended up porting the entire API infrastructure from another app in the same ecosystem — a sister project that already had Sanctum, API key management, and proper JSON exception handling. That meant:

  • Installing Sanctum (which in v4 on Laravel 12 doesn't need vendor:publish anymore — the migrations and config auto-discover, except they don't if your initial migration didn't include the personal_access_tokens table from the default Laravel skeleton)
  • Discovering that php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider" returns "No publishable resources" — because Sanctum v4 bundles everything internally
  • Having to manually create the personal_access_tokens migration because the original create_users_table migration in this project predated Sanctum being a default

Then I had to wire up the exception handling. The original bootstrap/app.php had an empty withExceptions block and no guest redirect logic, which meant API requests getting 302 redirects to the login page instead of 401 JSON responses. Classic.

The Modular Architecture Decision

With 21 modules already in the app, the question was: do I bolt the Outlook stuff onto the existing CaseManagement and EmailIntegration modules, or create a new one? I went with a new OutlookIntegration module. The reasoning was clean separation — the webhook lifecycle, Graph subscription management, and add-in auth routes are a self-contained concern. The module depends on CaseManagement (for the SupportCase model) and EmailIntegration (for the MsGraphDriver and InboundEmailProcessor), but it doesn't pollute either.

Running php artisan module:make OutlookIntegration worked fine, except the composer dump-autoload it triggers at the end timed out with a ProcessTimedOutException after 60 seconds. The module was created successfully though — I just had to run the autoload dump separately. When I tried to run migrations before the autoload finished, I got Class "Modules\OutlookIntegration\Providers\OutlookIntegrationServiceProvider" not found. Lesson: wait for the autoload.

Reusing Instead of Rebuilding

The original spec called for a new case_email_activities table to store webhook-delivered emails. But the existing CaseMessage model already had everything — graph_message_id, email_message_id, sender_email, body, html_body, direction flags. And the InboundEmailProcessor already handled deduplication (skip if graph_message_id exists), threading (match by case number in subject or In-Reply-To header), attachment saving (including inline images with CID URL rewriting), and case reopening (if status is resolved/closed, flip it back to open).

So the webhook job just constructs an InboundEmail DTO and feeds it through the existing processor. Zero new storage logic. The only new table is outlook_subscriptions to track the Graph webhook subscription lifecycle (subscription ID, client state for validation, expiry timestamps).

The "What If The Case Already Exists?" Problem

This one caught me mid-build. The original design assumed cases would only be created through the add-in. But in practice, a case might already exist — created by the email polling system, or manually by staff, or by someone else in the chain who used the add-in before. A forwarded email gets a new conversationId in Outlook, so you can't just match on that.

The solution was a three-strategy fallback in the conversation lookup endpoint:

  1. Primary: match outlook_conversation_id on the case (fast, indexed)
  2. Fallback 1: match the email's internetMessageId against case_messages.email_message_id (catches cases created by the email poller)
  3. Fallback 2: regex for [CASE-XXXX-XXXX] in the subject line (catches forwarded emails where the case number is in the thread)

On any fallback match, backfill the outlook_conversation_id so future lookups are instant.

Decrypting Credentials Across Apps

I needed to configure the Graph API credentials, but they were already set up in another app instance running on the same machine. The credentials were stored in a settings table using Laravel's Crypt::encryptString() — encrypted with that app's APP_KEY.

So I had to: find the right database (checked DB_DATABASE in the other app's .env), read the encrypted values, grab the other app's APP_KEY, manually decrypt the client secret using openssl_decrypt with the raw key bytes, then store the plaintext values in the target app's settings table where they'd get re-encrypted with the new app's key. The Setting model auto-encrypts anything matching patterns like "secret", "password", "api_key" — which is great until you need to move secrets between apps.

Service Accounts: The Gap Nobody Thinks About

Then came a subtle problem. The API key management UI (ported from the sister app) requires selecting a user to own each Sanctum token. That's fine for user-scoped API keys, but the Outlook add-in needs a service account key — a token that isn't tied to a real human user. Sanctum tokens are morph-linked to a User via tokenable, so you can't just create orphan tokens.

The fix was adding service account support:

  • A is_service_account boolean on the users table
  • Service account users get auto-generated emails like [email protected] and random passwords
  • They're blocked from interactive login (caught in the auth controller before the session is created)
  • They're filtered out of the admin users list
  • The API keys UI got a tabbed create modal: "User Key" vs "Service Account" with inline service account creation
if ($validated['service_account_id'] === 'new') {
    $saName = $validated['service_account_name'];
    $saSlug = Str::slug($saName);
    $user = User::create([
        'name' => $saName,
        'email' => "sa-{$saSlug}-" . Str::random(6) . '@service.local',
        'password' => Str::random(64),
        'is_active' => true,
        'is_admin' => false,
        'is_service_account' => true,
    ]);
}

It's one of those features that seems obvious in retrospect but doesn't exist until someone tries to create a machine-to-machine API key and realises the UI only shows a user dropdown.

What Worked

The end result is a complete Outlook integration built across three layers:

The Add-in — a standalone Vite + React app in outlook-addin/ with an Office.js manifest. It reads the current email via Office.context.mailbox.item, checks for existing cases with the multi-strategy fallback, and either shows case details or a pre-filled creation form. Auth is handled by a popup dialog that creates a Sanctum token and stores it in Office.context.roamingSettings.

The API — two endpoints behind auth:sanctum: POST /api/outlook/cases (create case from email metadata) and GET /api/outlook/conversation/{id} (lookup with fallback matching). Plus a webhook endpoint at POST /webhooks/outlook with CSRF excluded and Graph clientState validation.

The Webhook Pipeline — when a case is created, the service subscribes to Graph notifications for that conversation ID. New emails hit the webhook, get validated, and are dispatched to a queued job that fetches the full message from Graph and feeds it through the existing InboundEmailProcessor. A scheduled job renews expiring subscriptions every 4 hours.

The admin page at /admin/outlook-integration has the full setup guide with numbered steps, copy-able code blocks, direct links to Azure portal and Microsoft 365 admin center, a manifest download button, a live subscription status table, and an API reference. It's the kind of admin page I wish every integration had.

The MsGraphDriver only needed a two-word change — private to public on getClient() and getMailbox() — to let the subscription service reuse the existing authenticated Graph client.

What I Learned

  • Sanctum v4 in Laravel 12 doesn't need vendor publish. Migrations and config auto-discover. But if your create_users_table migration doesn't include personal_access_tokens (because the project predates Sanctum being default), you need to create the migration manually. vendor:publish will silently tell you there's nothing to publish.

  • bootstrap/app.php needs explicit API exception handling. Without it, unauthenticated API requests get 302 redirected to the login page. You need redirectGuestsTo returning null for API requests, plus explicit exception renderers for 401/403/404.

  • module:make can timeout on autoload dump in large projects. The module gets created fine — just run composer dump-autoload separately afterwards. Don't try to run artisan commands before it finishes or you'll get class-not-found errors.

  • Reuse existing processing pipelines before creating parallel ones. The original spec wanted a new case_email_activities table. The existing CaseMessage + InboundEmailProcessor already did everything needed. Adding a new table would have meant duplicate storage, split views, and two models representing the same concept.

  • Service accounts are a first-class concern for any API key system. If your token management UI only has a user dropdown, you'll hit a wall the moment someone needs a machine-to-machine token. Build service account support from the start.

  • Graph webhook subscriptions expire after ~3 days for mail resources. You need a renewal job. The 4229-minute maximum isn't documented prominently — you'll find it in the subscription resource type limits page if you dig.

  • Moving encrypted secrets between Laravel apps requires manual decryption. Each app's APP_KEY is different, so you can't just copy the ciphertext. Decrypt with the source key, then let the target app re-encrypt on save.

  • Office.js requires HTTPS for development. No exceptions. You need mkcert or similar to generate local certs, and Vite needs to be configured to serve over HTTPS.

Tech Notes

  • Stack: Laravel 12, Tailwind v4, Vite 7, React 19, Office.js, Microsoft Graph SDK v2.56
  • New dependency: laravel/sanctum ^4.3
  • Architecture: nwidart/laravel-modules — new OutlookIntegration module added (module #22)
  • Migrations: personal_access_tokens, add_outlook_conversation_id_to_support_cases, create_outlook_subscriptions, add_is_service_account_to_users
  • Key Graph SDK classes: Microsoft\Graph\Generated\Models\Subscription for webhook subscription CRUD, ClientCredentialContext for app-only auth
  • Manifest: Standard OfficeApp XML manifest with VersionOverridesV1_0 for ribbon button on MessageReadCommandSurface
  • CSRF exclusion pattern: ->withoutMiddleware(VerifyCsrfToken::class) on webhook route — matches existing pattern used by the email provider webhook

Commands worth remembering:

# Generate a module (run autoload separately for large projects)
php artisan module:make OutlookIntegration
composer dump-autoload

# Sideload in Outlook Web
# Settings → Get add-ins → My add-ins → Custom add-ins → Add from file

# Decrypt Laravel-encrypted value with a known APP_KEY
php -r "\$key = base64_decode('YOUR_BASE64_KEY'); \$payload = json_decode(base64_decode('ENCRYPTED_VALUE'), true); echo openssl_decrypt(base64_decode(\$payload['value']), 'aes-256-cbc', \$key, OPENSSL_RAW_DATA, base64_decode(\$payload['iv']));"
Zero Focus Dev

Zero Focus Dev

Breaking things, learning fast, sharing it all.