Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 23 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,42 @@
# kyledoesdev - Laravel Essentials
## kyledoesdev/essentials

Essential utilities for my Laravel projects.
Essential utilities for my Laravel applications — the helpers, macros and generators I'd otherwise copy between projects.

## Installation
### Installation

```bash
composer require kyledoesdev/essentials
```

## Features
### Features

### Global Timezone Helper
**Timezone detection** — `timezone()` resolves the timezone for the current request from the authenticated user, an IP geo lookup, then `config('app.timezone')`. Results are cached per IP address.

```php
timezone() // Returns the timezone of the current request()->ip()
```
**Carbon macro** — `Carbon::parse($date)->inUserTimezone()` shifts an instance into that timezone.

### Carbon Macro
**Action generator** — `php artisan make:action CreateUserAction` scaffolds action classes into `app/Actions`.

```php
Carbon::parse($created_at)->inUserTimezone() // Converts carbon instance to user's timezone
```
**Developer middleware** — `IsDeveloper` aborts with a 403 unless the authenticated user has `is_dev`.

**Model stats** — `HasStatsAfterEvents` records created and deleted counts through spatie/laravel-stats.

**String helpers** — `toSafeFileName()` strips characters that break downloads.

### Action Class Generator
### Configuration

```bash
php artisan make:action CreateUserAction
php artisan vendor:publish --tag=essentials-config
```

Generates action classes in `app/Actions` directory.
### Requirements

## Requirements

- Laravel 11+
- Laravel 12+
- PHP 8.4 / 8.5

## License
MIT license.
### License

essentials is open-sourced software licensed under the MIT license.

### Credits

Created by [kyledoesdev](https://github.com/kyledoesdev)
2 changes: 2 additions & 0 deletions config/essentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@
return [
'timezone' => [
'local_envs' => explode(',', (string) env('LOCAL_ENVS', 'local,development,dev')),
'ttl' => (int) env('TIMEZONE_CACHE_TTL', 86400),
'retry_after' => (int) env('TIMEZONE_RETRY_AFTER', 300),
],
];
2 changes: 1 addition & 1 deletion src/Concerns/HasStatsAfterEvents.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

trait HasStatsAfterEvents
{
public static function bootStatsAfterEvents()
public static function bootHasStatsAfterEvents()
{
static::created(function (Model $model) {
dispatch(fn() => StatsWriter::for(StatsEvent::class, ['name' => $model::statsClass()])->increase());
Expand Down
4 changes: 1 addition & 3 deletions src/Middleware/IsDeveloper.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@ class IsDeveloper
{
public function handle(Request $request, Closure $next): Response
{
if (auth()->check() && ! auth()->user()->is_dev) {
abort(403);
}
abort_unless(auth()->user()?->is_dev, 403);

return $next($request);
}
Expand Down
17 changes: 7 additions & 10 deletions src/Providers/MacroServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,19 @@
namespace Kyledoesdev\Essentials\Providers;

use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Support\ServiceProvider;

class MacroServiceProvider extends ServiceProvider
{
public function boot(): void
{
Carbon::macro('inUserTimezone', function () {
$timezone = auth()->user()?->timezone ?? session()->get('____tz');

if (is_null($timezone)) {
$timezone = timezone();

session()->put(['____tz' => $timezone]);
}
$this->bootCarbonMacros();
}

return $this->tz($timezone);
});
private function bootCarbonMacros(): void
{
Carbon::macro('inUserTimezone', fn () => $this->tz(timezone()));
CarbonImmutable::macro('inUserTimezone', fn () => $this->tz(timezone()));
}
}
71 changes: 54 additions & 17 deletions src/Services/TimezoneService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,85 @@

namespace Kyledoesdev\Essentials\Services;

use DateTimeZone;
use Exception;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

final class TimezoneService
{
private const CACHE_PREFIX = 'essentials:tz:';

public function detect(): string
{
return $this->normalize(Auth::user()?->timezone)
?? $this->fromIpAddress()
?? $this->fallback();
}

private function fromIpAddress(): ?string
{
$ip = request()->ip();

if (!$ip || $this->inDevEnv($ip)) {
return $this->default();
if (! $this->canLookup($ip)) {
return null;
}

return $this->fetchTimezone($ip);
$key = self::CACHE_PREFIX.$ip;
$cached = Cache::get($key);

if ($cached) {
return $cached;
}

$timezone = $this->normalize($this->lookup($ip));

Cache::put($key, $timezone ?? $this->fallback(), $this->ttlFor($timezone));

return $timezone;
}

private function fetchTimezone(string $ip): string
private function lookup(string $ip): ?string
{
$tz = rescue(fn () => Http::timeout(3)
->get("http://ip-api.com/json/{$ip}")
$timezone = rescue(fn () => Http::timeout(3)
->get("http://ip-api.com/json/{$ip}", ['fields' => 'status,timezone'])
->json('timezone')
);

return $tz ? $this->sanitize($tz) : $this->default();
return is_string($timezone) ? $timezone : null;
}

private function sanitize(string $timezone): string
private function canLookup(?string $ip): bool
{
return match ($timezone) {
return filled($ip)
&& ! in_array($ip, ['127.0.0.1', '::1'], true)
&& ! in_array(app()->environment(), (array) config('essentials.timezone.local_envs', []), true);
}

private function ttlFor(?string $timezone): int
{
return is_null($timezone)
? (int) config('essentials.timezone.retry_after', 300)
: (int) config('essentials.timezone.ttl', 86400);
}

private function normalize(?string $timezone): ?string
{
$timezone = match ($timezone) {
'Europe/Kiev' => 'Europe/Kyiv',
default => $timezone,
};
}

private function inDevEnv(string $ip): bool
{
return
in_array(app()->environment(), config('essentials.timezone.local_envs', [])) ||
in_array($ip, ['127.0.0.1', '::1']);
try {
return (new DateTimeZone((string) $timezone))->getName();
} catch (Exception) {
return null;
}
}

private function default(): string
private function fallback(): string
{
return config('app.timezone', 'UTC');
}
}
}
45 changes: 41 additions & 4 deletions tests/Feature/CarbonMacroTest.php
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
<?php

use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;

describe('Carbon Macro Helpers', function() {
test('inUserTimezone uses user timezone', function () {
$user = (object) ['timezone' => 'America/New_York'];
beforeEach(fn () => Http::preventStrayRequests());

describe('Carbon Macro Helpers', function () {
test('inUserTimezone uses user timezone', function () {
Auth::shouldReceive('user')
->once()
->andReturn($user);
->andReturn((object) ['timezone' => 'America/New_York']);

$date = Carbon::parse('2025-01-01 12:00:00')->inUserTimezone();

Expand All @@ -25,4 +27,39 @@

expect($date->tzName)->toBe(config('app.timezone'));
});

test('inUserTimezone uses the ip detected timezone when no user is authenticated', function () {
config(['essentials.timezone.local_envs' => []]);
request()->server->set('REMOTE_ADDR', '8.8.8.8');

Auth::shouldReceive('user')->once()->andReturn(null);

Http::fake(['ip-api.com/*' => Http::response(['timezone' => 'America/Chicago'])]);

$date = Carbon::parse('2025-01-01 12:00:00')->inUserTimezone();

expect($date->tzName)->toBe('America/Chicago');
});

test('inUserTimezone is available on CarbonImmutable', function () {
Auth::shouldReceive('user')
->once()
->andReturn((object) ['timezone' => 'America/New_York']);

$date = CarbonImmutable::parse('2025-01-01 12:00:00')->inUserTimezone();

expect($date)->toBeInstanceOf(CarbonImmutable::class)
->and($date->tzName)->toBe('America/New_York');
});

test('inUserTimezone shifts the underlying instant rather than relabelling it', function () {
Auth::shouldReceive('user')
->once()
->andReturn((object) ['timezone' => 'America/New_York']);

$date = Carbon::parse('2025-01-01 12:00:00', 'UTC')->inUserTimezone();

expect($date->format('Y-m-d H:i:s'))->toBe('2025-01-01 07:00:00')
->and($date->utc()->format('H:i:s'))->toBe('12:00:00');
});
});
18 changes: 18 additions & 0 deletions tests/Feature/HasStatsAfterEventsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

use Illuminate\Database\Eloquent\Model;
use Kyledoesdev\Essentials\Concerns\HasStatsAfterEvents;

describe('HasStatsAfterEvents', function () {
it('registers created and deleted listeners when a model uses the trait', function () {
$model = new class extends Model
{
use HasStatsAfterEvents;
};

$dispatcher = $model::getEventDispatcher();

expect($dispatcher->hasListeners('eloquent.created: '.get_class($model)))->toBeTrue()
->and($dispatcher->hasListeners('eloquent.deleted: '.get_class($model)))->toBeTrue();
});
});
Loading
Loading