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.
# install the package
composer require msahidurr/laravel-deploy-notifier
Overview
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.
Install & configure
Require the package, run :install, set your bot token and chat id.
Verify
Run :test for a sample message, :status for diagnostics.
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
gitavailable on the server, for commit metadata
Installation
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:
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
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
| Command | What it reports |
|---|---|
| :start | A 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. |
| :pull | The outcome of git pull — before/after commit, new commit count. Requires --deployment-id. |
| :success | The deployment completed. Releases the lock. Requires --deployment-id. |
| :fail | A deployment step failed. Releases the lock. Never reflects the deployment's outcome into its own exit code. Requires --deployment-id. |
| :rollback | A rollback happened, reported independently of the original failure. Requires --deployment-id. |
| :push | Manually reports a push — for testing only. See the callout below. |
| :prune | Deletes 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
Copy this into your application (or your deployment repository) and adjust the path, branch and build steps:
#!/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_IDonce, in the script, and pass it to every call. :startis 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. :failis reported from anERRtrap with the step name and exit code, then the original exit code is re-raised.
Receiving GitHub push notifications
- Set
DEPLOY_GITHUB_WEBHOOK_SECRETto a random value. - Set
DEPLOY_GITHUB_ALLOWED_REPOSITORIESto 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. -
In your GitHub repository settings, add a webhook:
- Payload URL:
https://your-app.test/webhooks/deploy-notifier/github(configurable viaDEPLOY_GITHUB_WEBHOOK_PATH) - Content type:
application/json - Secret: the same value as
DEPLOY_GITHUB_WEBHOOK_SECRET - Events: just
push
- Payload URL:
What the endpoint does
- Rejects anything whose
X-Hub-Signature-256doesn'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-Deliveryid, 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.
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:
$schedule->command('deploy-notifier:prune')->daily();
Slack and Discord
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.
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
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
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.