Deploy Notifier — User Guide
User guide

Deploy Notifier for Laravel

Git and deployment lifecycle notifications for Laravel — delivered to Telegram, and optionally Slack and Discord. This package reports what happened during a deployment. It never runs your deployment, and a notification problem never changes your script's exit status.

Composer package Signed GitHub webhook Safe by default
The six events this package reports
PUSH GitHub webhook START locks deploy PULL git metadata SUCCESS releases lock FAIL ROLLBACK
terminal
# install the package
composer require msahidurr/laravel-deploy-notifier

Overview

What this package does, and what it deliberately doesn't

Deploy Notifier reports what happened during a deployment — a push, a pull, a start, a success, a failure, a rollback — to Telegram (and optionally Slack and Discord). It is an event-driven notification library, not a deployment engine: it never executes your deployment commands, and it never decides whether a deployment succeeded. Your deploy script stays in charge of that; this package just tells your team about it.

1
Install & configure

Require the package, run :install, set your bot token and chat id.

2
Verify

Run :test for a sample message, :status for diagnostics.

3
Wire it up

Add the lifecycle commands to your deploy script. See the reference script below.

Requirements

  • PHP 8.1+
  • Laravel 10, 11, or 12
  • A Telegram bot token and at least one chat id
  • git available on the server, for commit metadata

Installation

terminal
composer require msahidurr/laravel-deploy-notifier

php artisan deploy-notifier:install

The service provider is auto-discovered. deploy-notifier:install publishes config/deploy-notifier.php and prints the remaining setup steps.

Configuration

Set these in your .env:

.env
DEPLOY_NOTIFIER_ENABLED=true
DEPLOY_NOTIFIER_PROJECT="My App"
DEPLOY_NOTIFIER_ENV=production
DEPLOY_NOTIFIER_SERVER=web-prod-01
DEPLOY_NOTIFIER_REPOSITORY_PATH=/var/www/my-app

DEPLOY_TELEGRAM_BOT_TOKEN=123456789:your-bot-token
DEPLOY_TELEGRAM_PRODUCTION_CHAT_ID=-100xxxxxxxxxx
DEPLOY_TELEGRAM_STAGING_CHAT_ID=-100xxxxxxxxxx

# only needed for the GitHub push webhook — see below
DEPLOY_GITHUB_WEBHOOK_SECRET=
DEPLOY_GITHUB_ALLOWED_REPOSITORIES=your-org/your-repo

Every Telegram chat id is mapped by environment name, so one app can notify production and staging separately — see the telegram.channels array in config/deploy-notifier.php for more than the three predefined environments.

Verify it works

terminal
php artisan deploy-notifier:test
php artisan deploy-notifier:status

:test sends a sample message. :status prints a full diagnostic — Git repository state, which channels are configured, and the event store in use — without sending anything.

Deployment lifecycle commands

One command per event. Each is independently safe to call.
CommandWhat it reports
:startA deployment has begun. Acquires a per-project/environment lock. Generates a deployment id if you don't pass one — see the callout below before relying on that.
:pullThe outcome of git pull — before/after commit, new commit count. Requires --deployment-id.
:successThe deployment completed. Releases the lock. Requires --deployment-id.
:failA deployment step failed. Releases the lock. Never reflects the deployment's outcome into its own exit code. Requires --deployment-id.
:rollbackA rollback happened, reported independently of the original failure. Requires --deployment-id.
:pushManually reports a push — for testing only. See the callout below.
:pruneDeletes expired dedup/lock rows. Only relevant with the database event store; a no-op on the cache store.

:pull, :success, :fail and :rollback require --deployment-id and fail immediately — exit 1, nothing sent — without it. The id is their entire reason for existing: silently fabricating one instead of failing would quietly break correlation with the matching :start call while still reporting as if everything worked. Generate the id yourself in your deploy script and pass the same one to every call.

Why :push is not proof of a real push

A server cannot know, by inspecting its own Git repository, that someone pushed to the remote. deploy-notifier:push only reports whatever you tell it — useful for testing formatting and routing, but not evidence that a push actually happened. For that, use the signed GitHub webhook below.

Wiring into a manual SSH deployment

A complete reference script

Copy this into your application (or your deployment repository) and adjust the path, branch and build steps:

examples/deploy.sh
#!/usr/bin/env bash
set -Eeuo pipefail

APP_PATH="/var/www/example-app"
BRANCH="main"
cd "$APP_PATH"

# generated once, passed to every notifier call below
DEPLOYMENT_ID="deploy-$(date +%Y%m%d-%H%M%S)-$$"
CURRENT_STEP="initialization"

# never let a notification problem affect the deployment's own exit status
notify() {
    php artisan "$@" || {
        echo "Warning: deploy-notifier command failed (ignored)" >&2
        return 0
    }
}

on_error() {
    local exit_code=$?
    trap - ERR
    notify deploy-notifier:fail \
        --deployment-id="$DEPLOYMENT_ID" \
        --step="$CURRENT_STEP" \
        --exit-code="$exit_code"
    exit "$exit_code"
}
trap on_error ERR

# :start is called WITHOUT the notify wrapper and its exit code IS
# checked — this is what makes the lock actually stop a second,
# overlapping deployment rather than merely report one
if ! php artisan deploy-notifier:start --deployment-id="$DEPLOYMENT_ID"; then
    echo "Another deployment is already in progress. Aborting." >&2
    exit 1
fi

CURRENT_STEP="capture previous commit"
PREVIOUS_COMMIT="$(git rev-parse HEAD)"

CURRENT_STEP="git pull"
git pull --ff-only origin "$BRANCH"

notify deploy-notifier:pull \
    --deployment-id="$DEPLOYMENT_ID" \
    --previous-commit="$PREVIOUS_COMMIT"

CURRENT_STEP="composer install"
composer install --no-interaction --prefer-dist --optimize-autoloader

CURRENT_STEP="database migration"
php artisan migrate --force

CURRENT_STEP="application optimization"
php artisan optimize

CURRENT_STEP="deployment completion"
notify deploy-notifier:success --deployment-id="$DEPLOYMENT_ID"

trap - ERR
echo "Deployment completed: $DEPLOYMENT_ID"
  • Generate DEPLOYMENT_ID once, in the script, and pass it to every call.
  • :start is called without the failure-swallowing wrapper used for the other commands, and its exit code is checked — that's what makes the lock stop a second, overlapping deploy.
  • Every other deploy-notifier:* call is wrapped so a notification problem can never affect the deployment's real exit code.
  • :fail is reported from an ERR trap with the step name and exit code, then the original exit code is re-raised.

Receiving GitHub push notifications

A signed webhook, not a guess from the local repository
  1. Set DEPLOY_GITHUB_WEBHOOK_SECRET to a random value.
  2. Set DEPLOY_GITHUB_ALLOWED_REPOSITORIES to a comma-separated allowlist (e.g. your-org/your-repo). Leaving it empty accepts any repository that knows the secret — set it explicitly in production.
  3. In your GitHub repository settings, add a webhook:
    • Payload URL: https://your-app.test/webhooks/deploy-notifier/github (configurable via DEPLOY_GITHUB_WEBHOOK_PATH)
    • Content type: application/json
    • Secret: the same value as DEPLOY_GITHUB_WEBHOOK_SECRET
    • Events: just push

What the endpoint does

  • Rejects anything whose X-Hub-Signature-256 doesn't match (constant-time comparison), before parsing the body.
  • Rejects payloads over DEPLOY_GITHUB_MAX_PAYLOAD_BYTES (default 1 MB).
  • Rejects repositories outside the allowlist, when one is configured.
  • Deduplicates by GitHub's X-GitHub-Delivery id, so retried deliveries don't double-notify.
  • Acknowledges and ignores any event that isn't push — pings, etc.
  • Never executes anything derived from the payload. It only reports.

The route is only registered when both deploy-notifier.enabled and deploy-notifier.github.webhook_secret are set — leaving the secret empty disables the endpoint entirely rather than exposing an unsigned one.

Event store: cache or database

Deduplication markers and the deployment lock live in whichever store deploy-notifier.store.driver selects:

cache (default)

Zero setup — backed by your app's cache (Redis, Memcached, the database cache driver, etc). Good enough for a single app server. State is lost if that cache is flushed.

database

A dedicated table, so dedup/lock state survives a cache flush and you can inspect it directly. Opt in with DEPLOY_NOTIFIER_STORE_DRIVER=database.

terminal — enabling the database store
php artisan vendor:publish --tag=deploy-notifier-migrations
php artisan migrate

Rows expire lazily (a stale row for a key is cleared the next time that key is written) and php artisan deploy-notifier:prune deletes every expired row — schedule it if you want the table swept even for keys that are never reused:

app/Console/Kernel.php
$schedule->command('deploy-notifier:prune')->daily();

Slack and Discord

Optional, in addition to Telegram
Telegram primary Slack optional Discord optional

Both disabled by default, both independent of each other, and both supplement Telegram rather than replace it — enable either, both, or neither. An event going to Telegram also goes to every other channel you've enabled.

.env
DEPLOY_SLACK_ENABLED=true
DEPLOY_SLACK_PRODUCTION_WEBHOOK_URL=https://hooks.slack.com/services/...
DEPLOY_SLACK_STAGING_WEBHOOK_URL=https://hooks.slack.com/services/...

DEPLOY_DISCORD_ENABLED=true
DEPLOY_DISCORD_PRODUCTION_WEBHOOK_URL=https://discord.com/api/webhooks/...
DEPLOY_DISCORD_STAGING_WEBHOOK_URL=https://discord.com/api/webhooks/...

A Slack or Discord incoming webhook URL is the secret for that channel (neither has a separate token) — keep it out of version control and rotate it if it leaks, the same as the Telegram bot token.

Safety guarantees

What this package relies on, so you don't have to worry about it

Notification failures never become deployment failures. Every transport catches delivery problems (timeout, API error, DNS failure) and logs them locally; none of them throws, and none ever flips a success into a failure or vice versa.

:fail never reports its own notification failure as a second deployment failure. That would risk a notification loop — delivery problems while reporting a failure are only ever logged.

Secrets never reach a chat or the log in cleartext. Every outgoing message is redacted for the bot token, webhook secrets and URLs, plus generic patterns like bearer tokens — and those are also scrubbed from exception messages, which routinely embed the full request URL.

Duplicate events are suppressed — both webhook deliveries (by GitHub's delivery id) and lifecycle events (by type and deployment id) — using an atomic write with a configurable TTL.

Overlapping deployments are blocked by a lock per project/environment, released by :success, :fail, or :rollback.

Extending

Every processed event dispatches DeploymentEventReceived, carrying the event data and whether delivery succeeded — including events that weren't delivered because no destination was configured. SendSlackNotification and SendDiscordNotification are working examples of listeners built on this: use either as a template for your own (Microsoft Teams, metrics, persistence, …). A second transport can also be added by binding your own implementation of NotificationTransport over the default TelegramTransport, if you want to replace the primary transport entirely rather than add a supplementary one.

Testing

terminal
composer install
composer test

The suite runs under Orchestra Testbench and fakes the chat-platform HTTP calls and the git process invocations, so it needs no real bot token, chat, webhook, or Git repository to run.

Scope & license

This is a notification library, not a deployment engine or a dashboard. Explicitly out of scope for now: executing deployment commands, a deployment-history UI, remote server orchestration, and automatic rollback execution. These may be added later without breaking the current API.

Released under the MIT license.

Deploy Notifier — msahidurr/laravel-deploy-notifier. This guide reflects the package's current configuration and command surface.