One of the most common things you'll do with groupBy
is to generate reports. For example, you might want to generate a monthly report for employee work hours (timesheet table):
Project Setup
Let's quickly go through the project setup to see what we have:
Migrations
Schema::create('employees', function (Blueprint $table) { $table->id(); $table->string('name'); $table->timestamps();}); Schema::create('employee_timesheets', function (Blueprint $table) { $table->id(); $table->foreignIdFor(Employee::class)->constrained(); $table->dateTime('start'); $table->dateTime('end')->nullable(); $table->timestamps();});
app/Models/Employee.php
class Employee extends Model{ use HasFactory; protected $fillable = [ 'name' ]; public function employeeTimesheets(): HasMany { return $this->hasMany(EmployeeTimesheet::class); }}
app/Models/EmployeeTimesheet.php
class EmployeeTimesheet extends Model{ use HasFactory; protected $fillable = [ 'employee_id', 'start', 'end', ]; public function employee(): BelongsTo { return $this->belongsTo(Employee::class); }}
database/factories/EmployeeFactory.php
class EmployeeFactory extends Factory{ protected $model = Employee::class; public function definition(): array { return [ 'name' => $this->faker->name(), ]; }}
database/factories/EmployeeTimesheetFactory.php
class EmployeeTimesheetFactory extends Factory{ protected $model = EmployeeTimesheet::class; public function definition(): array { $startTime = fake()->dateTimeBetween('-2 weeks'); return [ 'employee_id' => Employee::factory(), 'start' => $startTime, // Note that here, we take our start time and add 8 hours to it to keep our maximum time per entry to 8 hours 'end' => fake()->dateTimeBetween($startTime, $startTime->format('Y-m-d H:i:s').' + 8 hours'), ]; }}
database/seeders/DatabaseSeeder.php
// ...Employee::factory() ->count(10) ->has(EmployeeTimesheet::factory()->count(14)) ->create();
That's it. Now we have ten employees with 14 timesheets each:
Generating the Report
To generate our report, we will use the following code:
app/Http/Controllers/EmployeeTimesheetController.php
use App\Models\EmployeeTimesheet;use DB; // ... public function __invoke(){ $timesheet = EmployeeTimesheet::query() ->select('employee_id') // We will use this to calculate the total hours // TIMEDIFF will give us the difference between two times - end and start // TIME_TO_SEC will convert the time to seconds // SUM will sum all the seconds // SEC_TO_TIME will convert the seconds to time (00:00:00 format) ->addSelect( DB::raw('SEC_TO_TIME(SUM(TIME_TO_SEC(TIMEDIFF(end, start)))) as `total_hours`'), ) // We will use this to get the earliest start time ->addSelect( DB::raw('min(start) as `min_start`'), ) // We will use this to get the latest end time ->addSelect( DB::raw('max(end) as `max_end`'), ) // We will use this to get the total days ->addSelect( DB::raw('count(distinct(start)) as `total_days`') ) // Here, we apply the filters only to take the current month ->where('start', '>=', now()->startOfMonth()) ->where('end', '<=', now()->endOfMonth()) // Preloading employee details in a relationship ->with(['employee']) // Grouping by employee id ->groupBy('employee_id') // Ordering by total hours ->orderBy('total_hours', 'desc') ->get(); return view('employee.timesheet', ['timesheet' => $timesheet]);}
resources/views/employee/timesheet.blade.php
<table class="table-auto w-full mt-4"> <thead> <tr> <th class="px-4 py-2">Employee</th> <th class="px-4 py-2">Entries</th> <th class="px-4 py-2">Total Time</th> <th class="px-4 py-2">Earliest Date</th> <th class="px-4 py-2">Latest Date</th> </tr> </thead> <tbody> @foreach($timesheet as $entry) <tr> <td class="border px-4 py-2">{{ $entry->employee->name }}</td> <td class="border px-4 py-2">{{ $entry->total_days }}</td> <td class="border px-4 py-2">{{ $entry->total_hours }}</td> <td class="border px-4 py-2">{{ $entry->min_start }}</td> <td class="border px-4 py-2">{{ $entry->max_end }}</td> </tr> @endforeach </tbody></table>
This gives us two queries that are executed:
select `employee_id`, SEC_TO_TIME(SUM(TIME_TO_SEC(TIMEDIFF(end, start)))) as `total_hours`, min(start) as `min_start`, max(end) as `max_end`, count(distinct (start)) as `total_days`from `employee_timesheets`where `start` >= '2023-08-01 00:00:00' and `end` <= '2023-08-31 23:59:59'group by `employee_id`order by `total_hours` desc select *from `employees`where `employees`.`id` in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
And the result is:
I noticed that for each
select
, you added a->addSelect()
, but it's possible to leave everything grouped. These are just choices on how to write the code, right?At first I'm using it in my code like this:
You can definitely add them to a single function (this was done for readability and clarity), but keep in mind that it has to be:
->addSelect([])
and not just->select()
Or in other words:
Oops, Thanks for that observation, I didn't know that. At first the code worked perfectly as I had done, not even PHPStan on level 6 showed anything.
But I will correct.
Hi there, thanks for the course!
Today, I cloned the repo, ran tests, and got an error in
test_employee_timesheet_report
.It happens because of this condition (today is the 1st February):
app/tests/Feature/GrouppingExamples/TimesheetReportTest.php:57
Maybe it's not the best solution, you shall travel in time in the begining of function:
Like it was in the lesson Travel in Time to Reproduce Test Scenarios of Advanced Laravel Testing course.
Also, maybe someone already noticed it, in
test_the_application_returns_a_successful_response
get 302 status because of:app/routes/web.php:37
Thanks!
Hi, thank you for your comment! It is always nice to see people using multiple courses to reference things that might be off, thank you for that!
As per the issue - I will have to check and see if any adjustments are needed, but this just proves that working with dates is hard :) What works one day, might not function on a specific race-condition day.