Web · Lessons
Reporting engines for small businesses: cron, email and AdminLTE
Between 2017 and 2020 most of my freelance work for small businesses ended up being the same thing in different clothes: take data that lives in a spreadsheet or in someone's head, put it in a database, and send the owner a report they do not have to ask for. This post is about three of those projects and the pattern that connects them.
The main one is city_cars, a reporting engine for a Maruti Suzuki car dealership. The other two are multilevel-marketing-app, a Laravel platform with tree algorithms for downline tracking, and creative_accounting, a JavaScript accounting app for ledgers and transactions.
The problem
A dealership generates a steady stream of small records: enquiries, test drives, bookings, deliveries, service visits. None of it is complicated. What the owner wanted was not a dashboard to log into but a daily and monthly summary in their inbox, so they could read it on a phone before the showroom opened.
That framing changed how I built it. The admin panel existed so staff could enter data. The real output of the system was an email.
What I built
city_cars is Laravel on MySQL. Staff use an AdminLTE 2 panel to enter and correct records. A set of report classes turn those records into summaries. Laravel's task scheduler, driven by a single cron entry, runs those reports on a timetable and a mailer sends them to a list of recipients. The repo was started in April 2017 and I kept maintaining it until early 2019.
I chose AdminLTE because it gave me a complete admin shell (sidebar, tables, forms, alerts) for free. For a one-person project the most expensive thing is UI polish that nobody asked for, and a stock admin theme removes that temptation.
How it works
One cron line, many schedules
The server crontab contains exactly one entry. Everything else lives in the application, under version control, where it can be changed without SSH access.
# run the Laravel scheduler every minute; it decides what is due
* * * * * cd /var/www/city_cars && php artisan schedule:run >> /dev/null 2>&1
The schedule itself is declared in the console kernel. Each report is an artisan command, so it can also be run by hand when someone asks "can you resend yesterday's numbers".
protected function schedule(Schedule $schedule)
{
$schedule->command('report:daily')
->dailyAt('07:30')
->withoutOverlapping();
$schedule->command('report:monthly')
->monthlyOn(1, '08:00');
// remind staff about open enquiries with no follow-up
$schedule->command('report:stale-enquiries')
->weekdays()->at('18:00');
}
Reports as plain queries
Each report is a class with one job: run a few aggregate queries and hand an array to a Blade template. I resisted the urge to build a generic report builder. Three specific reports written plainly were easier to change when the owner asked for "the same thing but grouped by salesperson".
public function build(Carbon $day): array
{
return [
'enquiries' => Enquiry::whereDate('created_at', $day)->count(),
'bookings' => Booking::whereDate('booked_on', $day)->count(),
'by_model' => Booking::whereDate('booked_on', $day)
->select('model', DB::raw('count(*) as n'))
->groupBy('model')->get(),
];
}
The mailer wraps a Blade view in a Laravel Mailable. HTML email is hostile to layout, so the templates are tables with inline styles and nothing else. It looked plain, which was fine; the owner read it on a phone.
The MLM app: reports over a tree
The multi-level marketing platform had the same shape (Laravel, MySQL, a Bootstrap and jQuery admin, REST web services for a mobile client) with one twist: every member sits in a tree, and the numbers that matter are sums over subtrees. Who is in my downline, how much volume did it generate this month, which legs are weakest.
I stored the parent id on each member and precomputed the ancestor path, so subtree queries become a prefix match rather than a recursive walk. Payout calculation then walks the tree once from the leaves upward.
// members store a materialised path like "1/14/203/"
public function volume(Member $root, string $month): float
{
return Sale::join('members', 'members.id', '=', 'sales.member_id')
->where('members.path', 'like', $root->path . '%')
->where('sales.month', $month)
->sum('sales.amount');
}
The reports were then exactly the same idea as the dealership: a scheduled command, a query, a template, an email.
The accounting app: the same reports in the browser
creative_accounting, from 2020, was a bookkeeping tool for a small business built in JavaScript rather than PHP. Ledgers, transactions and reports, with the report screens rendered client side. By then I had written the same aggregate-and-group logic enough times that moving it to another language was mostly typing. The lesson it added was that an owner who wants to explore numbers wants a screen, and an owner who wants to be told numbers wants an email. Ask which one you are building before you start.
What I learned
- Put the schedule in the app, not the server. One crontab line plus Laravel's scheduler means every timing change is a commit, reviewable and reversible.
- Make every report a command. If it can run from cron it can run from the terminal, and that is how you debug it and how you resend it.
- Specific beats generic. A report builder would have been fun to write and painful to adapt. Three plain classes were boring and survived every change request.
- Email is the UI for busy owners. The admin panel got used by staff. The owner only ever saw the emails. Design for the person who reads the output, not the person who enters the input.
- Precompute tree paths. For hierarchies in SQL, a materialised path made subtree aggregates a single query and kept the reporting code identical to the flat case.
- Use a stock admin theme. AdminLTE 2 saved me weeks across these projects. Nobody paid me for a custom sidebar.
- Log the sends. The first support question is always "I did not get the report". A table of sent emails with timestamps answers it in seconds.
None of this is sophisticated, and that is the point. Small businesses rarely need a data platform. They need the right four numbers at the right time, reliably, and a way to fix bad data when they spot it. Cron, a few queries and an email cover that surprisingly well.
city_cars on GitHub >
multilevel-marketing-app on GitHub >
creative_accounting on GitHub >