acencyril / echo-bundle
Emails the site owner at each important step of the funnel (signup, payment...), plus a morning digest of yesterday's counters, so the owner never has to open the admin.
Package info
github.com/acencyril/echo-bundle
Type:symfony-bundle
pkg:composer/acencyril/echo-bundle
Requires
- php: >=8.1
- doctrine/dbal: ^3.6 || ^4.0
- doctrine/doctrine-bundle: ^2.11 || ^3.0
- doctrine/orm: ^2.17 || ^3.0
- psr/log: ^2.0 || ^3.0
- symfony/clock: ^6.4 || ^7.0 || ^8.0
- symfony/config: ^6.4 || ^7.0 || ^8.0
- symfony/console: ^6.4 || ^7.0 || ^8.0
- symfony/dependency-injection: ^6.4 || ^7.0 || ^8.0
- symfony/framework-bundle: ^6.4 || ^7.0 || ^8.0
- symfony/http-foundation: ^6.4 || ^7.0 || ^8.0
- symfony/http-kernel: ^6.4 || ^7.0 || ^8.0
- symfony/mailer: ^6.4 || ^7.0 || ^8.0
- symfony/translation: ^6.4 || ^7.0 || ^8.0
Requires (Dev)
- phpunit/phpunit: ^10.5 || ^11.5
- symfony/messenger: ^6.4 || ^7.0 || ^8.0
- symfony/yaml: ^6.4 || ^7.0 || ^8.0
Suggests
- symfony/messenger: Dispatch an EchoEvent on the bus, synchronously or through an async transport
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-26 17:44:36 UTC
README
Your site emails you at each important step of its funnel, and once each morning with yesterday's figures. You never have to open the admin to know how it is going.
A sign-up, a payment, a contact message: one short email, at once. Page views and the rest: counted per day. Every morning: one digest with yesterday's counters and its notable events. Nothing happened: nothing is sent.
Documentation française : README.fr.md
composer require acencyril/echo-bundle
- Plain-text emails, short lines, readable on a phone. No HTML, no tracking pixel, no link rewriting.
- At most N immediate emails per hour and per event, so a spike cannot flood your inbox; optional quiet hours. What is held back is in the next digest.
- Counters per day in your own database: no cookie, no visitor stored, no third party. Crawlers, monitors and link previews are left out of page views.
- Never breaks your app: a mail or database failure is logged, never thrown.
- French and English texts, through translation keys.
PHP 8.1+, Symfony 6.4, 7 or 8, Doctrine ORM 2.17+ or 3.
Quick start
1. Configure config/packages/echo.yaml (the Flex recipe creates it):
echo: owner: '%env(ECHO_OWNER)%' # who receives the emails sender: '%env(ECHO_SENDER)%' # the From address, accepted by your mail provider site_name: 'My site' # in every subject locale: en # en or fr timezone: 'Europe/Paris' admin_url: 'https://example.com/admin' throttle: 10 # immediate emails per hour and per event, at most quiet_hours: from: '20:00' to: '08:00' weekends: true events: # in this order in the digest signup: immediate contact: immediate payment: mode: immediate min_amount: 1000 # cents: smaller payments are only counted newsletter: digest page_view: mode: count skip_bots: true healthcheck: ignore
2. Create the tables (see Database):
php bin/console doctrine:migrations:diff php bin/console doctrine:migrations:migrate
3. Call it at each step of your funnel:
use Acencyril\EchoBundle\OwnerEcho; public function __construct(private readonly OwnerEcho $echo) {} $this->echo->event('signup', ['email' => $user->email, 'plan' => 'pro']); $this->echo->event('payment', ['amount' => 4900, 'currency' => 'EUR', 'email' => $order->email]); $this->echo->event('page_view');
4. Schedule the digest, once each morning:
0 8 * * * cd /path/to/app && php bin/console echo:digest
Events and modes
An event is a lowercase name (a-z, 0-9, _, -, ., up to 64 characters) and an
optional context. Each event has a mode:
| Mode | Emailed at once | Listed in the digest | Counted |
|---|---|---|---|
immediate |
yes, within the throttle and outside quiet hours | yes | yes |
digest |
no | yes | yes |
count |
no | no (just its number) | yes |
ignore |
no | no | no |
An event not listed under events takes default_mode (count by default): call
event() freely, decide later what deserves an email.
Per event:
| Key | Default | |
|---|---|---|
mode |
count |
One of the four above. signup: immediate is short for signup: { mode: immediate }. |
label |
— | The name shown in the emails. Default: the translation of echo.event.<name> (domain echo), else the name itself. |
min_amount |
— | In minor units (cents). An event whose amount is missing or lower is only counted: your own test payments do not ring. |
skip_bots |
false |
Drops crawlers, monitors, link previews and scripts (by user agent). For page views. |
throttle |
global | Immediate emails per hour for this event. |
The context
Scalars only (strings, numbers, booleans; dates, enums and stringable objects are converted), at most 20 keys, strings cut at 500 characters. Arrays and other objects are dropped. A few keys are read:
emailandamountcome first in subjects and digest lines;amountis in minor units (cents), formatted withcurrency(or the configuredcurrency,EURby default):4900→€49.00,49,00 €in French;user_agentis used byskip_bots, then dropped. Without it, the user agent of the current request is read. No user agent at all counts as a bot.
Do not put secrets in the context: notable events are stored with it (for the digest) and emailed.
From the event dispatcher or Messenger
The same call as an object:
use Acencyril\EchoBundle\EchoEvent; $dispatcher->dispatch(new EchoEvent('signup', ['email' => $email])); // EventDispatcher $bus->dispatch(new EchoEvent('payment', ['amount' => 4900])); // Messenger
With Messenger, route Acencyril\EchoBundle\EchoEvent to an async transport to keep the
email out of the request. In a worker there is no request: pass user_agent in the
context for events that skip bots.
Throttle and quiet hours
throttle (default 10) caps the immediate emails per hour and per event type. Past it,
events are still counted and listed in the digest, which says how many were not emailed
at the time.
quiet_hours (off by default): no immediate email between from and to (a window over
midnight works), nor all weekend with weekends: true. Hours are read in timezone
(default: PHP's). Events in that window wait for the digest.
The digest
php bin/console echo:digest # yesterday, in the configured timezone php bin/console echo:digest --dry-run # print it, send nothing php bin/console echo:digest --day=2026-10-24 php bin/console echo:digest --force # again, for a day already sent
One email: yesterday's counters (configured events first, in their order), then the
notable events (immediate and digest) with their time, at most 50. A day is sent once,
whoever runs the command; a day with nothing happening sends nothing. A failed send exits
with code 1 and can be run again. The command also deletes the notable events older than
retention_days (default 30); counters are kept.
With Symfony Scheduler instead of cron, a RecurringMessage::cron('0 8 * * *', new RunCommandMessage('echo:digest')) does the same.
What it looks like:
Subject: [My site] Saturday, October 24, 2026: Sign-up 3 · Payment 1 · Page views 312
Your day of Saturday, October 24, 2026
Sign-up: 3
Payment: 1
Page views: 312
Notable events
09:14 Sign-up — jane@example.com, plan: pro
11:02 Sign-up — sam@example.com, plan: free
15:47 Payment — jane@example.com, €49.00
21:30 Sign-up — lee@example.com, plan: free
1 of them not emailed at the time (quiet hours or hourly limit).
--
Admin: https://example.com/admin
Echo, for My site. What you receive is set in config/packages/echo.yaml.
Configuration reference
| Key | Default | |
|---|---|---|
owner |
— | Who receives the emails. Unset: events are counted, no email is sent (a warning is logged). |
sender |
— | The From address; the name shown is site_name. Unset: as for owner. |
site_name |
Echo |
In every subject, so several sites can share an inbox. |
locale |
en |
en or fr. |
timezone |
PHP's | Days, times and quiet hours. |
admin_url |
— | A link at the end of every email. |
currency |
EUR |
For an amount without a currency. |
default_mode |
count |
For events not listed. |
throttle |
10 |
Immediate emails per hour and per event. |
quiet_hours |
off | from, to (HH:MM), weekends. |
retention_days |
30 |
Notable events are deleted after this many days. |
events |
{} |
Per event: mode, label, min_amount, skip_bots, throttle. |
Addresses are not checked when the container is built (they usually come from
%env()%): a wrong one makes the emails fail, which is logged.
Database
Two small tables, mapped automatically (attribute mapping Acencyril\EchoBundle\Entity),
so doctrine:migrations:diff creates them:
echo_counter: one row per day and event (day,event,total);echo_entry: the notable events with their context, deleted afterretention_days.
Echo writes through DBAL (an atomic upsert for counters), never through the entity manager: it never flushes your pending changes. PostgreSQL, MySQL/MariaDB and SQLite use a native upsert; other platforms an update-then-insert.
The SQL, if you write the migration by hand:
-- PostgreSQL CREATE TABLE echo_counter (day DATE NOT NULL, event VARCHAR(64) NOT NULL, total INT NOT NULL, PRIMARY KEY (day, event)); CREATE TABLE echo_entry (id VARCHAR(32) NOT NULL, event VARCHAR(64) NOT NULL, context JSON NOT NULL, mailed BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id)); CREATE INDEX idx_echo_entry_created ON echo_entry (created_at); CREATE INDEX idx_echo_entry_event_created ON echo_entry (event, created_at); -- MySQL / MariaDB CREATE TABLE echo_counter (day DATE NOT NULL, event VARCHAR(64) NOT NULL, total INT NOT NULL, PRIMARY KEY (day, event)); CREATE TABLE echo_entry (id VARCHAR(32) NOT NULL, event VARCHAR(64) NOT NULL, context JSON NOT NULL, mailed TINYINT(1) NOT NULL, created_at DATETIME NOT NULL, INDEX idx_echo_entry_created (created_at), INDEX idx_echo_entry_event_created (event, created_at), PRIMARY KEY (id));
If you declare several entity managers (doctrine.orm.entity_managers), the mapping is
not added for you; add it to the manager of the default connection:
doctrine: orm: entity_managers: default: mappings: EchoBundle: type: attribute is_bundle: false dir: '%kernel.project_dir%/vendor/acencyril/echo-bundle/src/Entity' prefix: 'Acencyril\EchoBundle\Entity'
Texts
Texts live in the echo translation domain (translations/echo.en.yaml,
echo.fr.yaml): subjects, digest lines, and the labels of common events (page_view,
signup, login, contact, lead, order, checkout, payment, subscription,
cancellation, refund, trial, error). Name your own events in your
translations/echo.<locale>.yaml:
echo: event: quote_request: Quote request
The Symfony translator must be enabled (it is in a standard application).
Never breaks the app
event() never throws: an invalid name, a missing table, a refused email are logged
(channel echo with Monolog) and the call returns false. Mailer runs through Messenger
when your application routes emails there.
Develop
composer install vendor/bin/phpunit
The suite runs on in-memory SQLite. To run it on another database:
ECHO_TEST_DATABASE_URL="postgresql://user:pass@127.0.0.1:5432/echo_test" vendor/bin/phpunit.
License
MIT