Backend & architecture
Nightly jobs in Symfony: console commands, cron and EasyAdmin in a gym SaaS
4 min read
Nightly jobs in Symfony: console commands, cron and EasyAdmin in a gym SaaS
Not every feature in a SaaS product starts with a click. Some of the most useful work happens at night, when nobody is looking: closing what has expired, cleaning up, keeping the data honest so the next morning's screens make sense.
This post walks through a real example from MasMusculo, the gym management platform I work on at Osunasport: how training routines are managed in the back office with EasyAdmin, and how a nightly Symfony console command closes the routines whose time has run out. Entity and class names are simplified.
The domain: two kinds of "routine"
The first thing I had to get straight was that there are two different concepts that both get called "routine":
- Routine template (
Routine): created by a trainer. It has a name, a goal, an intensity, a number of training days per week and, for each day, a list of exercise rows (sets, reps, rest). Exercises themselves come from an exercise catalogue linked to machines and muscle groups. - Assigned routine (
MemberRoutine): a template assigned by a staff member to a specific gym member, with a start date and, eventually, an end date.
A member's current routine is simply the assigned routine whose end date is still null. When a new routine is assigned, the previous active one gets its end date set to the current date. Each assignment also records who created it: a reference to the employee and a copy of their email, and the reference is set to null if that employee is later deleted, so the history is not lost.
The back office: EasyAdmin in practice
The admin side is built with EasyAdmin (version 3 in this project). Its model is very CRUD-oriented and fits this kind of catalogue well. There are two kinds of classes:
- Dashboards, which mainly define the menu of each panel.
- CRUD controllers, one per entity, which define fields, actions, filters and search.
A typical CRUD controller for the exercise catalogue groups fields into panels and shows different fields depending on the page:
public function configureFields(string $pageName): iterable
{
yield IdField::new('id')->hideOnForm();
yield FormField::addPanel('General')->addCssClass('col-md-8');
yield TextField::new('name')->setColumns(12);
yield BooleanField::new('active');
yield TextareaField::new('description')->setColumns(12)->hideOnIndex();
yield FormField::addPanel('Relations')->addCssClass('col-md-12');
yield AssociationField::new('machines')->setColumns(4);
yield AssociationField::new('muscleGroups')->setColumns(4);
}
public function configureFilters(Filters $filters): Filters
{
return $filters->add('active')->add('muscleGroups');
}
A few things that saved me time:
hideOnIndex(),onlyOnDetail(),hideOnForm()and friends make it easy to keep list views short and forms complete.FormField::addTab()splits long forms. The routine form uses one tab for general data and another for the composition of the routine, day by day.- Fields do not have to map to a stored property. If the entity exposes a getter such as
getFullName(), aTextField::new('fullName')will display it, following Symfony's PropertyAccess conventions. For input-only fields in an assignment form, the same idea works with unmapped form fields.
One detail of the routine form matters later: each training day is its own collection field, and on submit EasyAdmin calls the entity's setter once per day. That is where a subtle Doctrine bug was hiding (a collection being filtered into a copy instead of modified in place), which I covered in a separate post.
The problem: routines that never end
Assigning a new routine closes the previous one, but many members are simply not assigned a new one on time. Their routine has a start date and a planned duration, yet its end date stays null forever, so it keeps appearing as "current" long after it should have finished.
The platform already had a console command, executed every night, with a list of maintenance tasks. The task was to add one more step: find the assigned routines without an end date that should already be finished, taking into account their start date and duration, and set their end date to the moment the command runs.
The command
Symfony console commands are services, so their dependencies are injected through the constructor. One thing worth remembering: entities are not services, so you cannot inject them. You inject the repository and the entity manager, and fetch entities through them.
The work was split into three small pieces:
- A method on the entity to close itself.
- A repository method that returns the candidates.
- The command step that ties them together and persists the result.
// Entity
public function isExpired(\DateTimeImmutable $now): bool
{
return $this->endDate === null
&& $this->startDate->modify("+{$this->durationDays} days") <= $now;
}
public function close(\DateTimeImmutable $at): void
{
$this->endDate = $at;
}
// Repository: open routines that have already started
public function findOpenStartedBefore(\DateTimeImmutable $now): array
{
return $this->createQueryBuilder('r')
->where('r.endDate IS NULL')
->andWhere('r.startDate <= :now')
->setParameter('now', $now)
->getQuery()
->getResult();
}
#[AsCommand(name: 'app:nightly-maintenance')]
final class NightlyMaintenanceCommand extends Command
{
public function __construct(
private readonly MemberRoutineRepository $routines,
private readonly EntityManagerInterface $em,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$now = new \DateTimeImmutable();
$closed = 0;
foreach ($this->routines->findOpenStartedBefore($now) as $routine) {
if ($routine->isExpired($now)) {
$routine->close($now);
$closed++;
}
}
$this->em->flush();
$output->writeln("Closed routines: {$closed}");
return Command::SUCCESS;
}
}
The duration is expressed here in days to keep the example simple. The important design choice is where each rule lives: the query narrows down the candidates, and the entity decides whether it is really expired, so the date logic stays in PHP and does not depend on database-specific date functions.
Running it every night
During development the command runs like any other:
php bin/console app:nightly-maintenance
php bin/console list # to see every available command
In production it is triggered by the server's scheduler once a night. An illustrative crontab entry:
0 3 * * * cd /path/to/app && php bin/console app:nightly-maintenance --env=prod
Because it only touches routines that are still open, running it twice is harmless: the second run finds nothing left to close.
What I took from it
- Model the states explicitly. "Current routine = end date is
null" is a simple rule, but it only works if something guarantees that the end date eventually gets set. - Keep commands thin. The command orchestrates; the repository selects; the entity knows its own rules. That made each piece easy to read and to test on its own.
- Make nightly jobs idempotent. A job that can safely run twice is a job you can rerun by hand at 9 a.m. without worrying.
- EasyAdmin is productive when you lean on its conventions (panels, tabs, page-specific fields, filters) and only drop to custom code where the domain really needs it.