The modules are idempotent and shell and command are not, so a playbook that reaches for them loses the property the tool exists for — and reports “changed” on every run, which trains everyone to ignore the summary.
# not idempotent: runs every time, always reports changed
- shell: php artisan migrate --force
# idempotent: the guard makes it a no-op when nothing is pending
- shell: php artisan migrate --force
register: migrate
changed_when: "'Nothing to migrate' not in migrate.stdout"
creates and removes are the other guards worth knowing — they skip the task entirely when a path exists or does not. The value of getting this right is that a run with zero changes means the machine matches the playbook, which is the only way to use the output as a check rather than as noise.