Initial commit of existing project

This commit is contained in:
Funky Waddle 2025-12-06 21:49:26 -06:00
parent ec8ffc7716
commit 3013bb5740
515 changed files with 41441 additions and 2 deletions

18
.editorconfig Normal file
View file

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4

70
.env.example Normal file
View file

@ -0,0 +1,70 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=base64:E0+Ieqm3EN0qO5yMEFEkXcpTY0FhglNAZUlHdEfn2eU=
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# Movies / OMDb
OMDB_API_KEY=
OMDB_LANGUAGE=en-US
OMDB_CACHE_TTL=3600
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

11
.gitattributes vendored Normal file
View file

@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
CHANGELOG.md export-ignore
README.md export-ignore
.github/workflows/browser-tests.yml export-ignore

1
.gitignore vendored
View file

@ -34,3 +34,4 @@ docs/_book
# TODO: where does this rule come from? # TODO: where does this rule come from?
test/ test/
.idea/

2
.prettierignore Normal file
View file

@ -0,0 +1,2 @@
resources/js/components/ui/*
resources/views/mail/*

26
.prettierrc Normal file
View file

@ -0,0 +1,26 @@
{
"semi": true,
"singleQuote": true,
"singleAttributePerLine": false,
"htmlWhitespaceSensitivity": "css",
"printWidth": 80,
"plugins": [
"prettier-plugin-organize-imports",
"prettier-plugin-tailwindcss"
],
"tailwindFunctions": [
"clsx",
"cn",
"cva"
],
"tailwindStylesheet": "resources/css/app.css",
"tabWidth": 4,
"overrides": [
{
"files": "**/*.yml",
"options": {
"tabWidth": 2
}
}
]
}

205
README.md
View file

@ -1,3 +1,204 @@
# PIMS # PIMS — Personal Inventory Management System
Personal Inventory Management System PIMS is a Laravel + Inertia.js + Vue 3 application intended to manage personal inventory. This repository is a modern Laravel 12 starter that pairs a PHP backend with a TypeScript/Vue frontend, bundled via Vite and styled with Tailwind CSS.
Note: This README was updated to reflect the current codebase. Where details are unclear in the repository, TODOs are explicitly noted for maintainers to fill in.
## Overview
- Backend: Laravel Framework ^12.0 (PHP ^8.2)
- Inertia.js (server: `inertiajs/inertia-laravel` ^2.x)
- Frontend: Vue 3, TypeScript, Vite 7, Tailwind CSS 4
- Auth: Laravel Fortify
- Dev tooling: Laravel Boost, Pail (logs), Pint (code style), ESLint, Prettier
- Package managers: Composer (PHP) and npm (Node)
- Default entry point: `public/index.php`
## Requirements
- PHP 8.2+
- Composer 2.x
- Node.js 18+ (recommended LTS) and npm 9+
- SQLite (default on first-run) or another database supported by Laravel
- The project creates `database/database.sqlite` on project creation and runs migrations automatically in some script flows.
## Getting Started
### 1) Clone and configure
```
git clone <YOUR_REPO_URL> pims
cd pims
```
Create your environment file and app key, install dependencies, run migrations, and build assets. The Composer `setup` script automates these steps:
```
composer run setup
```
What `composer run setup` does:
- `composer install`
- Copy `.env.example` to `.env` if it does not exist
- `php artisan key:generate`
- `php artisan migrate --force`
- `npm install`
- `npm run build`
If you prefer to do these manually:
```
cp .env.example .env # if .env.example is populated; see TODO below
php artisan key:generate
composer install
php artisan migrate
npm install
npm run dev # or npm run build
```
### 2) Run the app in development
Option A — Single terminal processes:
```
php artisan serve
php artisan queue:listen --tries=1 # optional if you use queues
php artisan pail --timeout=0 # optional live log viewer
npm run dev # Vite dev server for assets
```
Option B — Use the combined dev script (runs server, queue, logs, and Vite together):
```
composer run dev
```
Server will be available at the host/port shown in `php artisan serve` output (typically http://127.0.0.1:8000). The default route `/` renders an Inertia page `Welcome`. The `dashboard` route is protected by `auth` and `verified` middleware.
### Optional: Inertia SSR (Server-Side Rendering)
This project includes scripts to build SSR bundles and start the Inertia SSR server.
```
composer run dev:ssr # will run php server, queue, logs, and the Inertia SSR server
```
Or build SSR assets without starting the SSR process:
```
npm run build:ssr
```
## Scripts
Composer:
- `composer run setup` — Full setup: install PHP deps, ensure `.env`, generate key, migrate, install Node deps, build assets
- `composer run dev` — Concurrent dev processes (server, queue worker, logs, Vite)
- `composer run dev:ssr` — Build SSR assets and run server + queue + logs + Inertia SSR
- `composer run test` — Clear config cache and run tests via `php artisan test`
npm:
- `npm run dev` — Vite dev server
- `npm run build` — Build production assets
- `npm run build:ssr` — Build production assets and SSR bundle
- `npm run lint` — ESLint with auto-fix
- `npm run format` — Prettier write on `resources/`
- `npm run format:check` — Prettier check
## Environment Variables
- The repository contains an `.env.example` file, but it is currently empty.
- TODO: Populate `.env.example` with the required variables for this app (e.g., `APP_NAME`, `APP_ENV`, `APP_KEY`, database config, mail config, etc.).
- On first setup, the scripts will generate `APP_KEY` automatically.
- Database: the projects Composer post-create hook creates `database/database.sqlite` and runs migrations; you can continue with SQLite (set `DB_CONNECTION=sqlite`) or configure MySQL/PostgreSQL/etc. in `.env`.
## Project Structure
High-level directories of interest:
- `app/` — Laravel application code (models, jobs, controllers, etc.)
- `bootstrap/` — Laravel bootstrapping
- `config/` — Configuration files
- `database/` — Migrations, seeders; SQLite file may live here (`database.sqlite`)
- `public/` — Web server document root; main PHP entry point is `public/index.php`
- `resources/` — Frontend assets (Vue, TypeScript, styles)
- `routes/` — Route definitions (e.g., `web.php` renders Inertia pages)
- `storage/` — Framework storage (logs, cache, sessions)
- `tests/` — Test suite (Pest + Laravel plugin)
- `vite.config.ts` — Vite build configuration
- `composer.json` — PHP dependencies and Composer scripts
- `package.json` — Node dependencies and npm scripts
## Modules
This application is organized to support feature modules under `app/Modules/` (e.g., Movies, Books, Games, Collectibles). Each module owns its domain logic and ships with its own `README.md` documenting endpoints, data models, services/contracts, environment variables, and development notes.
- Current module(s):
- Movies — see `app/Modules/Movies/README.md` for detailed documentation.
As additional modules are added (Books, Games, Collectibles, etc.), refer to their respective `app/Modules/<Module>/README.md` files for module-specific guidance.
## Running Tests
You can run tests with either Composer or Artisan directly:
```
composer run test
# or
php artisan test
```
The project is configured for Pest with `pestphp/pest` and `pest-plugin-laravel` in `require-dev`.
## Database & Migrations
Run pending migrations:
```
php artisan migrate
```
Seeders/factories are available under `database/` if defined.
Default behavior suggests SQLite can be used out of the box. To switch to another DB, update your `.env` accordingly and re-run migrations.
## Linting & Formatting
```
npm run lint # ESLint
npm run format # Prettier write
npm run format:check # Prettier check
```
PHP code style is managed by Laravel Pint (installed as a dev dependency). You can run it via:
```
./vendor/bin/pint
```
## Development Notes
- Logs: `php artisan pail --timeout=0` starts a live log viewer.
- Queues: `php artisan queue:listen --tries=1` is used in the dev script.
- Docker: Laravel Sail is present as a dev dependency, but the repo does not include a Sail config section here.
- TODO: Confirm whether Sail should be the preferred local environment and document the `./vendor/bin/sail` workflow if applicable.
## Deployment
General Laravel deployment notes:
1. Ensure environment variables are configured and `APP_KEY` is set.
2. Run migrations: `php artisan migrate --force`.
3. Build assets: `npm ci && npm run build`.
4. Cache optimizations (optional):
- `php artisan config:cache`
- `php artisan route:cache`
- `php artisan view:cache`
TODO: Add specific deployment instructions (hosting provider, CI/CD, SSR process, queue workers, scheduler) once decided.
## License
This project is licensed under the MIT License (per `composer.json`).
TODO: If a `LICENSE` file is missing in the repository root, add one with the MIT license text.

View file

@ -0,0 +1,39 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
],
'password' => $this->passwordRules(),
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
]);
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Actions\Fortify;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => $input['password'],
])->save();
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
class PasswordController extends Controller
{
/**
* Show the user's password settings page.
*/
public function edit(): Response
{
return Inertia::render('settings/Password');
}
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => $validated['password'],
]);
return back();
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ProfileUpdateRequest;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Show the user's profile settings page.
*/
public function edit(Request $request): Response
{
return Inertia::render('settings/Profile', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => $request->session()->get('status'),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return to_route('profile.edit');
}
/**
* Delete the user's profile.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validate([
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\TwoFactorAuthenticationRequest;
use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Features;
class TwoFactorAuthenticationController extends Controller implements HasMiddleware
{
/**
* Get the middleware that should be assigned to the controller.
*/
public static function middleware(): array
{
return Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword')
? [new Middleware('password.confirm', only: ['show'])]
: [];
}
/**
* Show the user's two-factor authentication settings page.
*/
public function show(TwoFactorAuthenticationRequest $request): Response
{
$request->ensureStateIsValid();
return Inertia::render('settings/TwoFactor', [
'twoFactorEnabled' => $request->user()->hasEnabledTwoFactorAuthentication(),
'requiresConfirmation' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
]);
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class HandleAppearance
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
View::share('appearance', $request->cookie('appearance') ?? 'system');
return $next($request);
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Inspiring;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @var string
*/
protected $rootView = 'app';
/**
* Determines the current asset version.
*
* @see https://inertiajs.com/asset-versioning
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
[$message, $author] = str(Inspiring::quotes()->random())->explode('-');
return [
...parent::share($request),
'name' => config('app.name'),
'quote' => ['message' => trim($message), 'author' => trim($author)],
'auth' => [
'user' => $request->user(),
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Settings;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest;
use Laravel\Fortify\Features;
use Laravel\Fortify\InteractsWithTwoFactorState;
class TwoFactorAuthenticationRequest extends FormRequest
{
use InteractsWithTwoFactorState;
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return Features::enabled(Features::twoFactorAuthentication());
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [];
}
}

52
app/Models/User.php Normal file
View file

@ -0,0 +1,52 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, TwoFactorAuthenticatable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'two_factor_secret',
'two_factor_recovery_codes',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_confirmed_at' => 'datetime',
];
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Modules\Movies\Http\Requests\AcceptMovieRequest;
use App\Modules\Movies\Services\Contracts\MovieProvider;
use App\Modules\Movies\Services\Contracts\UpsertMovieServiceInterface;
use Illuminate\Http\JsonResponse;
class AcceptMovieController extends Controller
{
public function store(
AcceptMovieRequest $request,
MovieProvider $provider,
UpsertMovieServiceInterface $upserter
): JsonResponse {
$providerId = $request->validated('provider_id');
$mode = $request->validated('mode'); // overwrite|duplicate
$details = $provider->details($providerId);
$movie = $upserter->handle($details, $mode);
return response()->json([
'status' => 'ok',
'movie_id' => $movie->id,
]);
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Modules\Movies\Http\Requests\DestroyMovieRequest;
use App\Modules\Movies\Services\Contracts\DeleteMovieServiceInterface;
use Illuminate\Http\JsonResponse;
class DeleteMovieController extends Controller
{
public function destroy(int $movie, DestroyMovieRequest $request, DeleteMovieServiceInterface $service): JsonResponse
{
$service->handle($movie);
return response()->json([
'status' => 'ok',
'deleted' => true,
]);
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Inertia\Inertia;
use Inertia\Response;
class EditMoviePageController extends Controller
{
public function __invoke(string $movie): Response
{
return Inertia::render('admin/movies/Edit', [
'movieId' => (int) $movie,
]);
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Modules\Movies\Http\Requests\ExistsMovieRequest;
use App\Modules\Movies\Services\Contracts\CheckMovieExistsServiceInterface;
use Illuminate\Http\JsonResponse;
class ExistsMovieController extends Controller
{
public function __invoke(
ExistsMovieRequest $request,
CheckMovieExistsServiceInterface $service
): JsonResponse {
$provider = $request->validated('provider') ?? 'omdb';
$providerId = $request->validated('provider_id');
$movie = $service->findByProviderId($provider, $providerId);
return response()->json([
'exists' => (bool) $movie,
'movie_id' => $movie?->id,
]);
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Inertia\Inertia;
use Inertia\Response;
class MoviesListPageController extends Controller
{
public function __invoke(): Response
{
return Inertia::render('admin/movies/List');
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Inertia\Inertia;
use Inertia\Response;
class MoviesPageController extends Controller
{
public function __invoke(): Response
{
return Inertia::render('admin/movies/Index');
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Modules\Movies\Http\Requests\SearchMoviesRequest;
use App\Modules\Movies\Services\Contracts\MovieProvider;
use Illuminate\Http\JsonResponse;
class SearchMoviesController extends Controller
{
public function __invoke(SearchMoviesRequest $request, MovieProvider $provider): JsonResponse
{
$q = $request->validated('q');
$page = (int) $request->validated('page');
$result = $provider->search($q, $page);
return response()->json($result);
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Modules\Movies\Http\Requests\UpdateMovieRequest;
use App\Modules\Movies\Services\Contracts\UpdateMovieServiceInterface;
use Illuminate\Http\JsonResponse;
class UpdateMovieController extends Controller
{
public function update(int $movie, UpdateMovieRequest $request, UpdateMovieServiceInterface $service): JsonResponse
{
$data = $request->validated();
$updated = $service->handle($movie, $data);
return response()->json([
'status' => 'ok',
'movie' => $updated,
]);
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Browse\Entities;
use App\Http\Controllers\Controller;
use App\Modules\Movies\Services\Contracts\GetActorWithMoviesServiceInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ActorShowController extends Controller
{
public function __invoke(string $actor, Request $request, GetActorWithMoviesServiceInterface $service): JsonResponse
{
$id = (int) $actor;
$result = $service->handle($id, [
'q' => $request->string('q')->toString(),
'per_page' => $request->integer('per_page', 20),
'sort' => $request->string('sort')->toString() ?: 'title_asc',
]);
return response()->json([
'entity' => $result['entity'],
'movies' => $result['movies'],
]);
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Browse\Entities;
use App\Http\Controllers\Controller;
use App\Modules\Movies\Services\Contracts\GetDirectorWithMoviesServiceInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DirectorShowController extends Controller
{
public function __invoke(string $director, Request $request, GetDirectorWithMoviesServiceInterface $service): JsonResponse
{
$id = (int) $director;
$result = $service->handle($id, [
'q' => $request->string('q')->toString(),
'per_page' => $request->integer('per_page', 20),
'sort' => $request->string('sort')->toString() ?: 'title_asc',
]);
return response()->json([
'entity' => $result['entity'],
'movies' => $result['movies'],
]);
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Browse\Entities;
use App\Http\Controllers\Controller;
use App\Modules\Movies\Services\Contracts\GetGenreWithMoviesServiceInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class GenreShowController extends Controller
{
public function __invoke(string $genre, Request $request, GetGenreWithMoviesServiceInterface $service): JsonResponse
{
$id = (int) $genre;
$result = $service->handle($id, [
'q' => $request->string('q')->toString(),
'per_page' => $request->integer('per_page', 20),
'sort' => $request->string('sort')->toString() ?: 'title_asc',
]);
return response()->json([
'entity' => $result['entity'],
'movies' => $result['movies'],
]);
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Browse\Entities;
use App\Http\Controllers\Controller;
use App\Modules\Movies\Services\Contracts\GetStudioWithMoviesServiceInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class StudioShowController extends Controller
{
public function __invoke(string $studio, Request $request, GetStudioWithMoviesServiceInterface $service): JsonResponse
{
$id = (int) $studio;
$result = $service->handle($id, [
'q' => $request->string('q')->toString(),
'per_page' => $request->integer('per_page', 20),
'sort' => $request->string('sort')->toString() ?: 'title_asc',
]);
return response()->json([
'entity' => $result['entity'],
'movies' => $result['movies'],
]);
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Browse;
use App\Http\Controllers\Controller;
use App\Modules\Movies\Services\Contracts\ShowMovieServiceInterface;
use Illuminate\Http\JsonResponse;
class MovieShowController extends Controller
{
public function __invoke(string $movie, ShowMovieServiceInterface $service): JsonResponse
{
$id = (int) $movie;
$model = $service->getById($id);
return response()->json($model);
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Browse;
use App\Http\Controllers\Controller;
use App\Modules\Movies\Services\Contracts\ListMoviesServiceInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MoviesIndexController extends Controller
{
public function __invoke(Request $request, ListMoviesServiceInterface $service): JsonResponse
{
$paginator = $service->list([
'q' => $request->string('q')->toString(),
'per_page' => $request->integer('per_page', 20),
'sort' => $request->string('sort')->toString() ?: 'title_asc',
'genre' => $request->string('genre')->toString(),
'rating' => $request->string('rating')->toString(),
'year_min' => $request->has('year_min') ? $request->integer('year_min') : null,
'year_max' => $request->has('year_max') ? $request->integer('year_max') : null,
'actor' => $request->string('actor')->toString(),
'director' => $request->string('director')->toString(),
'studio' => $request->string('studio')->toString(),
]);
return response()->json($paginator);
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Pages;
use App\Http\Controllers\Controller;
use Inertia\Inertia;
use Inertia\Response;
class ActorsShowPageController extends Controller
{
public function __invoke(string $actor): Response
{
return Inertia::render('actors/Show', [
'entityId' => (int) $actor,
]);
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Pages;
use App\Http\Controllers\Controller;
use Inertia\Inertia;
use Inertia\Response;
class DirectorsShowPageController extends Controller
{
public function __invoke(string $director): Response
{
return Inertia::render('directors/Show', [
'entityId' => (int) $director,
]);
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Pages;
use App\Http\Controllers\Controller;
use Inertia\Inertia;
use Inertia\Response;
class GenresShowPageController extends Controller
{
public function __invoke(string $genre): Response
{
return Inertia::render('genres/Show', [
'entityId' => (int) $genre,
]);
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Pages;
use App\Http\Controllers\Controller;
use Inertia\Inertia;
use Inertia\Response;
class PublicMovieShowPageController extends Controller
{
public function __invoke(string $movie): Response
{
return Inertia::render('movies/Show', [
'movieId' => (int) $movie,
]);
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Pages;
use App\Http\Controllers\Controller;
use Inertia\Inertia;
use Inertia\Response;
class PublicMoviesPageController extends Controller
{
public function __invoke(): Response
{
return Inertia::render('movies/Index');
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Modules\Movies\Http\Controllers\Pages;
use App\Http\Controllers\Controller;
use Inertia\Inertia;
use Inertia\Response;
class StudiosShowPageController extends Controller
{
public function __invoke(string $studio): Response
{
return Inertia::render('studios/Show', [
'entityId' => (int) $studio,
]);
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Modules\Movies\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AcceptMovieRequest extends FormRequest
{
public function authorize(): bool
{
return (bool) $this->user();
}
public function rules(): array
{
return [
'provider_id' => ['required', 'string'],
'mode' => ['required', Rule::in(['overwrite', 'duplicate'])],
];
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Modules\Movies\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class DestroyMovieRequest extends FormRequest
{
public function authorize(): bool
{
return (bool) $this->user();
}
public function rules(): array
{
return [];
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Modules\Movies\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ExistsMovieRequest extends FormRequest
{
public function authorize(): bool
{
return (bool) $this->user();
}
public function rules(): array
{
return [
'provider' => ['sometimes', 'string'],
'provider_id' => ['required', 'string'],
];
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Modules\Movies\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class SearchMoviesRequest extends FormRequest
{
public function authorize(): bool
{
return (bool) $this->user();
}
public function rules(): array
{
return [
'q' => ['required', 'string', 'min:2'],
'page' => ['sometimes', 'integer', 'min:1'],
];
}
public function validated($key = null, $default = null)
{
$data = parent::validated();
$data['page'] = isset($data['page']) ? (int) $data['page'] : 1;
return $key ? ($data[$key] ?? $default) : $data;
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Modules\Movies\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateMovieRequest extends FormRequest
{
public function authorize(): bool
{
// Any authenticated user can manage
return (bool) $this->user();
}
public function rules(): array
{
return [
// Scalars (all optional)
'title' => ['sometimes', 'string', 'min:1'],
'original_title' => ['sometimes', 'nullable', 'string'],
'description' => ['sometimes', 'nullable', 'string'],
'poster_url' => ['sometimes', 'nullable', 'url'],
'backdrop_url' => ['sometimes', 'nullable', 'url'],
'rating' => ['sometimes', 'nullable', 'string', 'max:20'],
'release_date' => ['sometimes', 'nullable', 'date'],
'year' => ['sometimes', 'nullable', 'integer', 'min:1800', 'max:3000'],
'runtime' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:10000'],
// Relations via free-text chips (arrays of strings)
'genres' => ['sometimes', 'array'],
'genres.*' => ['string', 'min:1'],
'actors' => ['sometimes', 'array'],
'actors.*' => ['string', 'min:1'],
'directors' => ['sometimes', 'array'],
'directors.*' => ['string', 'min:1'],
'studios' => ['sometimes', 'array'],
'studios.*' => ['string', 'min:1'],
'countries' => ['sometimes', 'array'],
'countries.*' => ['string', 'min:1'],
'languages' => ['sometimes', 'array'],
'languages.*' => ['string', 'min:1'],
];
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Modules\Movies\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Factories\Factory;
class Actor extends Model
{
use HasFactory;
protected $table = 'actors';
protected $fillable = ['name'];
public function movies(): BelongsToMany { return $this->belongsToMany(Movie::class, 'movie_actor'); }
protected static function newFactory(): Factory
{
return \Database\Factories\ActorFactory::new();
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Modules\Movies\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Factories\Factory;
class Country extends Model
{
use HasFactory;
protected $table = 'countries';
protected $fillable = ['name'];
public function movies(): BelongsToMany { return $this->belongsToMany(Movie::class, 'movie_country'); }
protected static function newFactory(): Factory
{
return \Database\Factories\CountryFactory::new();
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Modules\Movies\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Factories\Factory;
class Director extends Model
{
use HasFactory;
protected $table = 'directors';
protected $fillable = ['name'];
public function movies(): BelongsToMany { return $this->belongsToMany(Movie::class, 'movie_director'); }
protected static function newFactory(): Factory
{
return \Database\Factories\DirectorFactory::new();
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Modules\Movies\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Factories\Factory;
class Genre extends Model
{
use HasFactory;
protected $table = 'genres';
protected $fillable = ['name'];
public function movies(): BelongsToMany { return $this->belongsToMany(Movie::class, 'movie_genre'); }
protected static function newFactory(): Factory
{
return \Database\Factories\GenreFactory::new();
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Modules\Movies\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Factories\Factory;
class Language extends Model
{
use HasFactory;
protected $table = 'languages';
protected $fillable = ['name'];
public function movies(): BelongsToMany { return $this->belongsToMany(Movie::class, 'movie_language'); }
protected static function newFactory(): Factory
{
return \Database\Factories\LanguageFactory::new();
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Modules\Movies\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Factories\Factory;
class Movie extends Model
{
use HasFactory;
protected $table = 'movies';
protected $fillable = [
'provider',
'provider_id',
'external_ids',
'title',
'original_title',
'description',
'poster_url',
'backdrop_url',
'rating',
'release_date',
'year',
'runtime',
];
protected $casts = [
'external_ids' => 'array',
'release_date' => 'date',
];
public function genres(): BelongsToMany { return $this->belongsToMany(Genre::class, 'movie_genre'); }
public function actors(): BelongsToMany { return $this->belongsToMany(Actor::class, 'movie_actor'); }
public function directors(): BelongsToMany { return $this->belongsToMany(Director::class, 'movie_director'); }
public function studios(): BelongsToMany { return $this->belongsToMany(Studio::class, 'movie_studio'); }
public function countries(): BelongsToMany { return $this->belongsToMany(Country::class, 'movie_country'); }
public function languages(): BelongsToMany { return $this->belongsToMany(Language::class, 'movie_language'); }
protected static function newFactory(): Factory
{
return \Database\Factories\MovieFactory::new();
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Modules\Movies\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Factories\Factory;
class Studio extends Model
{
use HasFactory;
protected $table = 'studios';
protected $fillable = ['name'];
public function movies(): BelongsToMany { return $this->belongsToMany(Movie::class, 'movie_studio'); }
protected static function newFactory(): Factory
{
return \Database\Factories\StudioFactory::new();
}
}

View file

@ -0,0 +1,93 @@
<?php
namespace App\Modules\Movies\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;
use App\Modules\Movies\Services\Contracts\MovieProvider;
use App\Modules\Movies\Services\Omdb\OmdbMovieProvider;
use App\Modules\Movies\Services\Contracts\ListMoviesServiceInterface;
use App\Modules\Movies\Services\Contracts\ShowMovieServiceInterface;
use App\Modules\Movies\Services\Contracts\UpsertMovieServiceInterface;
use App\Modules\Movies\Services\Contracts\CheckMovieExistsServiceInterface;
use App\Modules\Movies\Services\Contracts\{GetActorWithMoviesServiceInterface, GetDirectorWithMoviesServiceInterface, GetStudioWithMoviesServiceInterface, GetGenreWithMoviesServiceInterface};
use App\Modules\Movies\Services\Contracts\{UpdateMovieServiceInterface, DeleteMovieServiceInterface};
use App\Modules\Movies\Services\Browse\ListMoviesService;
use App\Modules\Movies\Services\Browse\ShowMovieService;
use App\Modules\Movies\Services\UpsertMovieFromProvider;
use App\Modules\Movies\Services\CheckMovieExistsService;
use App\Modules\Movies\Services\Browse\Entities\{GetActorWithMoviesService, GetDirectorWithMoviesService, GetStudioWithMoviesService, GetGenreWithMoviesService};
use App\Modules\Movies\Services\{UpdateMovieService, DeleteMovieService};
class MoviesServiceProvider extends ServiceProvider
{
public function register(): void
{
// Bind movie provider based on config
$this->app->bind(MovieProvider::class, function () {
$provider = config('movies.provider', 'omdb');
return match ($provider) {
'omdb' => new OmdbMovieProvider(),
default => new OmdbMovieProvider(),
};
});
// Bind service interfaces to implementations
$this->app->bind(ListMoviesServiceInterface::class, ListMoviesService::class);
$this->app->bind(ShowMovieServiceInterface::class, ShowMovieService::class);
$this->app->bind(UpsertMovieServiceInterface::class, UpsertMovieFromProvider::class);
$this->app->bind(CheckMovieExistsServiceInterface::class, CheckMovieExistsService::class);
// Entity services
$this->app->bind(GetActorWithMoviesServiceInterface::class, GetActorWithMoviesService::class);
$this->app->bind(GetDirectorWithMoviesServiceInterface::class, GetDirectorWithMoviesService::class);
$this->app->bind(GetStudioWithMoviesServiceInterface::class, GetStudioWithMoviesService::class);
$this->app->bind(GetGenreWithMoviesServiceInterface::class, GetGenreWithMoviesService::class);
// Admin management services
$this->app->bind(UpdateMovieServiceInterface::class, UpdateMovieService::class);
$this->app->bind(DeleteMovieServiceInterface::class, DeleteMovieService::class);
}
public function boot(): void
{
// Module routes
Route::middleware(['web', 'auth', 'verified'])
->prefix('admin/movies')
->as('admin.movies.')
->group(__DIR__ . '/../routes/admin.php');
// Public API (JSON) routes
Route::middleware(['web'])
->prefix('api/movies')
->as('api.movies.')
->group(__DIR__ . '/../routes/api.php');
// Entity API routes (actors/directors/studios/genres)
Route::middleware(['web'])
->prefix('api')
->as('api.movies.entities.')
->group(function () {
Route::prefix('actors')->as('actors.')->group(__DIR__ . '/../routes/api_actors.php');
Route::prefix('directors')->as('directors.')->group(__DIR__ . '/../routes/api_directors.php');
Route::prefix('studios')->as('studios.')->group(__DIR__ . '/../routes/api_studios.php');
Route::prefix('genres')->as('genres.')->group(__DIR__ . '/../routes/api_genres.php');
});
// Public Inertia pages
Route::middleware(['web'])
->prefix('movies')
->as('movies.')
->group(__DIR__ . '/../routes/pages.php');
// Entity Inertia pages
Route::middleware(['web'])
->as('entities.pages.')
->group(function () {
Route::prefix('actors')->as('actors.')->group(__DIR__ . '/../routes/pages_actors.php');
Route::prefix('directors')->as('directors.')->group(__DIR__ . '/../routes/pages_directors.php');
Route::prefix('studios')->as('studios.')->group(__DIR__ . '/../routes/pages_studios.php');
Route::prefix('genres')->as('genres.')->group(__DIR__ . '/../routes/pages_genres.php');
});
}
}

View file

@ -0,0 +1,60 @@
# Movies Module
This module implements service-first Movies functionality, including:
- Admin search + accept flow (OMDb)
- Public browsing (Inertia pages) backed by JSON API
- Strict controller→service separation
## Endpoints
Admin (auth required)
- GET /admin/movies → Inertia admin page (search + accept)
- GET /admin/movies/search?q=&page= → JSON results from OMDb (10 per page)
- GET /admin/movies/exists?provider_id= → Duplicate check
- POST /admin/movies/accept { provider_id, mode: overwrite|duplicate } → Persist movie
Public
- Inertia pages: GET /movies, GET /movies/{id}
- JSON API: GET /api/movies, GET /api/movies/{id}
## Data model
- Fully normalized tables: movies, genres, actors, directors, studios, countries, languages + pivot tables.
- Provider ID is not unique. Duplicates are allowed when “Save as Duplicate” is selected.
- Images are stored as remote URLs only (not downloaded).
## Services & Contracts
- Provider: App\\Modules\\Movies\\Services\\Contracts\\MovieProvider (OMDb implementation)
- Browse: ListMoviesServiceInterface, ShowMovieServiceInterface
- Admin: UpsertMovieServiceInterface, CheckMovieExistsServiceInterface
All DI bindings are registered in MoviesServiceProvider.
## Admin flow
1. Search calls /admin/movies/search (OMDb search). List shows poster, title, year only (no details calls).
2. Accept button:
- If unique, accepts immediately (no modal).
- If duplicate exists by provider_id, shows modal: Overwrite | Save as Duplicate | Cancel.
3. On successful accept, a small toast appears and the row is marked “Accepted”.
## Public browsing
- /movies (Inertia) fetches from /api/movies with infinite scroll. Each row shows poster, title, year, rating, genres, and a ~50-word description snippet.
- /movies/{id} (Inertia) fetches from /api/movies/{id} and shows details with all relations.
## Environment
Add to .env (values shown as placeholders):
```
OMDB_API_KEY=
OMDB_LANGUAGE=en-US
OMDB_CACHE_TTL=3600
```
Set your real OMDb key locally (per user decision). Timezone is America/Chicago.
## Development
- Run backend: `php artisan serve`
- Frontend dev: `npm install && npm run dev`
- Tests: `./vendor/bin/pest`
## Notes
- Pagination: OMDb search uses 10/page; public DB pagination defaults to 20/page (configurable at request).
- Controller classes contain no business logic; all logic resides in services.

View file

@ -0,0 +1,39 @@
<?php
namespace App\Modules\Movies\Services\Browse\Entities;
use App\Modules\Movies\Models\{Actor, Movie};
use App\Modules\Movies\Services\Contracts\GetActorWithMoviesServiceInterface;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class GetActorWithMoviesService implements GetActorWithMoviesServiceInterface
{
/**
* @param int $id
* @param array{q?: string|null, per_page?: int|null} $params
* @return array{entity: Actor, movies: LengthAwarePaginator}
*/
public function handle(int $id, array $params = []): array
{
$actor = Actor::query()->find($id);
if (!$actor) {
throw (new ModelNotFoundException())->setModel(Actor::class, [$id]);
}
$perPage = max(1, min((int)($params['per_page'] ?? 20), 50));
$q = isset($params['q']) ? trim((string)$params['q']) : '';
$movies = Movie::query()
->whereHas('actors', fn($q2) => $q2->whereKey($actor->id))
->with(['genres'])
->when($q !== '', fn($query) => $query->where('title', 'like', "%{$q}%"))
->latest()
->paginate($perPage);
return [
'entity' => $actor,
'movies' => $movies,
];
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Modules\Movies\Services\Browse\Entities;
use App\Modules\Movies\Models\{Director, Movie};
use App\Modules\Movies\Services\Contracts\GetDirectorWithMoviesServiceInterface;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class GetDirectorWithMoviesService implements GetDirectorWithMoviesServiceInterface
{
/**
* @param int $id
* @param array{q?: string|null, per_page?: int|null} $params
* @return array{entity: Director, movies: LengthAwarePaginator}
*/
public function handle(int $id, array $params = []): array
{
$director = Director::query()->find($id);
if (!$director) {
throw (new ModelNotFoundException())->setModel(Director::class, [$id]);
}
$perPage = max(1, min((int)($params['per_page'] ?? 20), 50));
$q = isset($params['q']) ? trim((string)$params['q']) : '';
$movies = Movie::query()
->whereHas('directors', fn($q2) => $q2->whereKey($director->id))
->with(['genres'])
->when($q !== '', fn($query) => $query->where('title', 'like', "%{$q}%"))
->latest()
->paginate($perPage);
return [
'entity' => $director,
'movies' => $movies,
];
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace App\Modules\Movies\Services\Browse\Entities;
use App\Modules\Movies\Models\{Genre, Movie};
use App\Modules\Movies\Services\Contracts\GetGenreWithMoviesServiceInterface;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class GetGenreWithMoviesService implements GetGenreWithMoviesServiceInterface
{
/**
* @param int $id
* @param array{q?: string|null, per_page?: int|null} $params
* @return array{entity: Genre, movies: LengthAwarePaginator}
*/
public function handle(int $id, array $params = []): array
{
$genre = Genre::query()->find($id);
if (!$genre) {
throw (new ModelNotFoundException())->setModel(Genre::class, [$id]);
}
$perPage = max(1, min((int)($params['per_page'] ?? 20), 50));
$q = isset($params['q']) ? trim((string)$params['q']) : '';
$sort = isset($params['sort']) ? trim((string)$params['sort']) : 'title_asc';
$moviesQuery = Movie::query()
->whereHas('genres', fn($q2) => $q2->whereKey($genre->id))
->with(['genres'])
->when($q !== '', fn($query) => $query->where('title', 'like', "%{$q}%"));
switch ($sort) {
case 'title_desc':
$moviesQuery->orderBy('title', 'desc');
break;
case 'newest':
$moviesQuery->orderBy('created_at', 'desc');
break;
case 'oldest':
$moviesQuery->orderBy('created_at', 'asc');
break;
case 'year_asc':
$moviesQuery->orderBy('year', 'asc');
break;
case 'year_desc':
$moviesQuery->orderBy('year', 'desc');
break;
case 'title_asc':
default:
$moviesQuery->orderBy('title', 'asc');
break;
}
$movies = $moviesQuery->paginate($perPage);
return [
'entity' => $genre,
'movies' => $movies,
];
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace App\Modules\Movies\Services\Browse\Entities;
use App\Modules\Movies\Models\{Studio, Movie};
use App\Modules\Movies\Services\Contracts\GetStudioWithMoviesServiceInterface;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class GetStudioWithMoviesService implements GetStudioWithMoviesServiceInterface
{
/**
* @param int $id
* @param array{q?: string|null, per_page?: int|null} $params
* @return array{entity: Studio, movies: LengthAwarePaginator}
*/
public function handle(int $id, array $params = []): array
{
$studio = Studio::query()->find($id);
if (!$studio) {
throw (new ModelNotFoundException())->setModel(Studio::class, [$id]);
}
$perPage = max(1, min((int)($params['per_page'] ?? 20), 50));
$q = isset($params['q']) ? trim((string)$params['q']) : '';
$sort = isset($params['sort']) ? trim((string)$params['sort']) : 'title_asc';
$moviesQuery = Movie::query()
->whereHas('studios', fn($q2) => $q2->whereKey($studio->id))
->with(['genres'])
->when($q !== '', fn($query) => $query->where('title', 'like', "%{$q}%"));
switch ($sort) {
case 'title_desc':
$moviesQuery->orderBy('title', 'desc');
break;
case 'newest':
$moviesQuery->orderBy('created_at', 'desc');
break;
case 'oldest':
$moviesQuery->orderBy('created_at', 'asc');
break;
case 'year_asc':
$moviesQuery->orderBy('year', 'asc');
break;
case 'year_desc':
$moviesQuery->orderBy('year', 'desc');
break;
case 'title_asc':
default:
$moviesQuery->orderBy('title', 'asc');
break;
}
$movies = $moviesQuery->paginate($perPage);
return [
'entity' => $studio,
'movies' => $movies,
];
}
}

View file

@ -0,0 +1,98 @@
<?php
namespace App\Modules\Movies\Services\Browse;
use App\Modules\Movies\Models\Movie;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use App\Modules\Movies\Services\Contracts\ListMoviesServiceInterface;
class ListMoviesService implements ListMoviesServiceInterface
{
/**
* Return a paginated list of movies with optional search, filters, and sort.
*
* @param array{
* q?: string|null,
* per_page?: int|null,
* sort?: string|null,
* genre?: string|null,
* rating?: string|null,
* year_min?: int|null,
* year_max?: int|null,
* actor?: string|null,
* director?: string|null,
* studio?: string|null,
* } $params
*/
public function list(array $params = []): LengthAwarePaginator
{
$perPage = (int) ($params['per_page'] ?? 20);
$q = isset($params['q']) ? trim((string) $params['q']) : '';
$sort = isset($params['sort']) ? trim((string) $params['sort']) : 'title_asc';
$genre = isset($params['genre']) ? trim((string) $params['genre']) : '';
$rating = isset($params['rating']) ? trim((string) $params['rating']) : '';
$yearMin = isset($params['year_min']) ? (int) $params['year_min'] : null;
$yearMax = isset($params['year_max']) ? (int) $params['year_max'] : null;
$actor = isset($params['actor']) ? trim((string) $params['actor']) : '';
$director = isset($params['director']) ? trim((string) $params['director']) : '';
$studio = isset($params['studio']) ? trim((string) $params['studio']) : '';
$query = Movie::query()->with(['genres', 'actors', 'directors']);
if ($q !== '') {
$query->where('title', 'like', "%{$q}%");
}
if ($genre !== '') {
$ln = mb_strtolower($genre);
$query->whereHas('genres', fn($q2) => $q2->whereRaw('lower(name) = ?', [$ln]));
}
if ($rating !== '') {
$query->where('rating', $rating);
}
if ($yearMin !== null) {
$query->where('year', '>=', $yearMin);
}
if ($yearMax !== null) {
$query->where('year', '<=', $yearMax);
}
if ($actor !== '') {
$ln = mb_strtolower($actor);
$query->whereHas('actors', fn($q2) => $q2->whereRaw('lower(name) = ?', [$ln]));
}
if ($director !== '') {
$ln = mb_strtolower($director);
$query->whereHas('directors', fn($q2) => $q2->whereRaw('lower(name) = ?', [$ln]));
}
if ($studio !== '') {
$ln = mb_strtolower($studio);
$query->whereHas('studios', fn($q2) => $q2->whereRaw('lower(name) = ?', [$ln]));
}
// Sorting
switch ($sort) {
case 'title_desc':
$query->orderBy('title', 'desc');
break;
case 'newest':
$query->orderBy('created_at', 'desc');
break;
case 'oldest':
$query->orderBy('created_at', 'asc');
break;
case 'year_asc':
$query->orderBy('year', 'asc');
break;
case 'year_desc':
$query->orderBy('year', 'desc');
break;
case 'title_asc':
default:
$query->orderBy('title', 'asc');
break;
}
return $query->paginate($perPage);
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Modules\Movies\Services\Browse;
use App\Modules\Movies\Models\Movie;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use App\Modules\Movies\Services\Contracts\ShowMovieServiceInterface;
class ShowMovieService implements ShowMovieServiceInterface
{
/**
* Fetch a single movie with its relations by ID.
*/
public function getById(int $id): Movie
{
$movie = Movie::query()
->with(['genres', 'actors', 'directors', 'studios', 'countries', 'languages'])
->find($id);
if (!$movie) {
throw (new ModelNotFoundException())->setModel(Movie::class, [$id]);
}
return $movie;
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Modules\Movies\Services;
use App\Modules\Movies\Models\Movie;
use App\Modules\Movies\Services\Contracts\CheckMovieExistsServiceInterface;
class CheckMovieExistsService implements CheckMovieExistsServiceInterface
{
public function findByProviderId(string $provider, string $providerId): ?Movie
{
return Movie::query()
->where('provider', $provider)
->where('provider_id', $providerId)
->first();
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Modules\Movies\Services\Contracts;
use App\Modules\Movies\Models\Movie;
interface CheckMovieExistsServiceInterface
{
public function findByProviderId(string $provider, string $providerId): ?Movie;
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Modules\Movies\Services\Contracts;
interface DeleteMovieServiceInterface
{
public function handle(int $id): void;
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Modules\Movies\Services\Contracts;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use App\Modules\Movies\Models\Actor;
interface GetActorWithMoviesServiceInterface
{
/**
* @param int $id Actor ID
* @param array{q?: string|null, per_page?: int|null} $params
* @return array{entity: Actor, movies: LengthAwarePaginator}
*/
public function handle(int $id, array $params = []): array;
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Modules\Movies\Services\Contracts;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use App\Modules\Movies\Models\Director;
interface GetDirectorWithMoviesServiceInterface
{
/**
* @param int $id Director ID
* @param array{q?: string|null, per_page?: int|null} $params
* @return array{entity: Director, movies: LengthAwarePaginator}
*/
public function handle(int $id, array $params = []): array;
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Modules\Movies\Services\Contracts;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use App\Modules\Movies\Models\Genre;
interface GetGenreWithMoviesServiceInterface
{
/**
* @param int $id Genre ID
* @param array{q?: string|null, per_page?: int|null} $params
* @return array{entity: Genre, movies: LengthAwarePaginator}
*/
public function handle(int $id, array $params = []): array;
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Modules\Movies\Services\Contracts;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use App\Modules\Movies\Models\Studio;
interface GetStudioWithMoviesServiceInterface
{
/**
* @param int $id Studio ID
* @param array{q?: string|null, per_page?: int|null} $params
* @return array{entity: Studio, movies: LengthAwarePaginator}
*/
public function handle(int $id, array $params = []): array;
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Modules\Movies\Services\Contracts;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
interface ListMoviesServiceInterface
{
/**
* @param array{q?: string|null, per_page?: int|null} $params
*/
public function list(array $params = []): LengthAwarePaginator;
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Modules\Movies\Services\Contracts;
use Illuminate\Support\Collection;
interface MovieProvider
{
/**
* Search movies by title.
*
* @param string $query
* @param int $page 1-based page number
* @return array{results: Collection, total: int, page: int, has_more: bool}
*/
public function search(string $query, int $page = 1): array;
/**
* Fetch full movie details by provider-specific ID.
*
* @param string $id
* @return array Movie details normalized to our internal structure
*/
public function details(string $id): array;
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Modules\Movies\Services\Contracts;
use App\Modules\Movies\Models\Movie;
interface ShowMovieServiceInterface
{
public function getById(int $id): Movie;
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Modules\Movies\Services\Contracts;
use App\Modules\Movies\Models\Movie;
interface UpdateMovieServiceInterface
{
/**
* Update a movie's scalar fields and sync relations.
*
* @param int $id Movie ID
* @param array $data Accepts scalar fields (title, original_title, description, poster_url, backdrop_url, rating, release_date, year, runtime)
* and relation name arrays: genres, actors, directors, studios, countries, languages (each array of names or IDs)
*/
public function handle(int $id, array $data): Movie;
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Modules\Movies\Services\Contracts;
use App\Modules\Movies\Models\Movie;
interface UpsertMovieServiceInterface
{
/**
* Persist a movie and all relations from normalized provider details.
*
* @param array $details Output of MovieProvider::details
* @param string $mode 'overwrite'|'duplicate'
* @return Movie
*/
public function handle(array $details, string $mode = 'overwrite'): Movie;
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Modules\Movies\Services;
use App\Modules\Movies\Models\Movie;
use App\Modules\Movies\Services\Contracts\DeleteMovieServiceInterface;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class DeleteMovieService implements DeleteMovieServiceInterface
{
public function handle(int $id): void
{
/** @var Movie|null $movie */
$movie = Movie::query()->find($id);
if (!$movie) {
throw (new ModelNotFoundException())->setModel(Movie::class, [$id]);
}
// Hard delete cascades through pivots due to FK cascade in migrations
$movie->delete();
}
}

View file

@ -0,0 +1,161 @@
<?php
namespace App\Modules\Movies\Services\Omdb;
use App\Modules\Movies\Services\Contracts\MovieProvider;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Collection;
class OmdbMovieProvider implements MovieProvider
{
protected string $baseUrl;
protected ?string $apiKey;
protected int $cacheTtl;
public function __construct()
{
$this->baseUrl = rtrim(config('movies.omdb.base_url', 'https://www.omdbapi.com/'), '/');
$this->apiKey = config('movies.omdb.api_key');
$this->cacheTtl = (int) config('movies.omdb.cache_ttl', 3600);
}
public function search(string $query, int $page = 1): array
{
$query = trim($query);
if ($query === '') {
return ['results' => collect(), 'total' => 0, 'page' => 1, 'has_more' => false];
}
$cacheKey = sprintf('omdb.search.%s.p%s', md5($query), $page);
$data = Cache::remember($cacheKey, $this->cacheTtl, function () use ($query, $page) {
$response = Http::acceptJson()
->timeout(6)
->retry(3, 300)
->get($this->baseUrl, [
'apikey' => $this->apiKey,
's' => $query,
'type' => 'movie',
'page' => $page,
]);
if ($response->failed()) {
abort(502, 'Couldnt reach OMDb. Please try again.');
}
return $response->json();
});
$results = collect();
$total = 0;
$hasMore = false;
if (isset($data['Response']) && $data['Response'] === 'True' && isset($data['Search'])) {
$total = (int) ($data['totalResults'] ?? 0);
$hasMore = $page * 10 < $total; // OMDb pages of 10
$results = collect($data['Search'])->map(function ($item) {
return [
'provider' => 'omdb',
'provider_id' => $item['imdbID'] ?? null,
'title' => $item['Title'] ?? null,
'year' => $item['Year'] ?? null,
'poster_url' => ($item['Poster'] ?? 'N/A') !== 'N/A' ? $item['Poster'] : null,
];
});
}
return [
'results' => $results,
'total' => $total,
'page' => $page,
'has_more' => $hasMore,
];
}
public function details(string $id): array
{
$id = trim($id);
$cacheKey = sprintf('omdb.details.%s', $id);
$data = Cache::remember($cacheKey, $this->cacheTtl, function () use ($id) {
$response = Http::acceptJson()
->timeout(6)
->retry(3, 300)
->get($this->baseUrl, [
'apikey' => $this->apiKey,
'i' => $id,
'plot' => 'full',
'type' => 'movie',
]);
if ($response->failed()) {
abort(502, 'Couldnt reach OMDb. Please try again.');
}
return $response->json();
});
if (!isset($data['Response']) || $data['Response'] !== 'True') {
// Map explicit OMDb errors that indicate provider-side issues differently if needed
// Default to not found for details when OMDb says False
abort(404, 'Movie not found in OMDb');
}
// Normalize to internal structure
$genres = $this->splitList($data['Genre'] ?? '');
$actors = $this->splitList($data['Actors'] ?? '');
$directors = $this->splitList($data['Director'] ?? '');
$studios = $this->splitList($data['Production'] ?? '');
$countries = $this->splitList($data['Country'] ?? '');
$languages = $this->splitList($data['Language'] ?? '');
return [
'provider' => 'omdb',
'provider_id' => $data['imdbID'] ?? null,
'external_ids' => [
'imdb' => $data['imdbID'] ?? null,
'omdb' => $data['imdbID'] ?? null,
],
'title' => $data['Title'] ?? null,
'original_title' => $data['Title'] ?? null,
'description' => $data['Plot'] ?? null,
'poster_url' => ($data['Poster'] ?? 'N/A') !== 'N/A' ? $data['Poster'] : null,
'backdrop_url' => null,
'genres' => $genres,
'rating' => $data['Rated'] ?? null,
'release_date' => $this->parseDate($data['Released'] ?? null),
'year' => isset($data['Year']) ? (int) substr($data['Year'], 0, 4) : null,
'runtime' => $this->parseRuntime($data['Runtime'] ?? null),
'actors' => $actors,
'directors' => $directors,
'studios' => $studios,
'countries' => $countries,
'languages' => $languages,
];
}
protected function splitList(?string $value): array
{
if (!$value || $value === 'N/A') return [];
return collect(explode(',', $value))
->map(fn($v) => trim($v))
->filter()
->values()
->all();
}
protected function parseRuntime(?string $runtime): ?int
{
if (!$runtime) return null;
if (preg_match('/(\d+) min/i', $runtime, $m)) {
return (int) $m[1];
}
return null;
}
protected function parseDate(?string $date): ?string
{
if (!$date || $date === 'N/A') return null;
// OMDb uses formats like "01 Jan 2000"
$ts = strtotime($date);
return $ts ? date('Y-m-d', $ts) : null;
}
}

View file

@ -0,0 +1,68 @@
<?php
namespace App\Modules\Movies\Services;
use App\Modules\Movies\Models\{Movie, Genre, Actor, Director, Studio, Country, Language};
use App\Modules\Movies\Services\Contracts\UpdateMovieServiceInterface;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB;
class UpdateMovieService implements UpdateMovieServiceInterface
{
public function handle(int $id, array $data): Movie
{
/** @var Movie|null $movie */
$movie = Movie::query()->find($id);
if (!$movie) {
throw (new ModelNotFoundException())->setModel(Movie::class, [$id]);
}
return DB::transaction(function () use ($movie, $data) {
// Scalars
$movie->fill(array_intersect_key($data, array_flip([
'title', 'original_title', 'description', 'poster_url', 'backdrop_url', 'rating', 'release_date', 'year', 'runtime',
])));
$movie->save();
// Relations via free-text chips (names)
$this->syncNames($movie, Genre::class, 'genres', $data['genres'] ?? []);
$this->syncNames($movie, Actor::class, 'actors', $data['actors'] ?? []);
$this->syncNames($movie, Director::class, 'directors', $data['directors'] ?? []);
$this->syncNames($movie, Studio::class, 'studios', $data['studios'] ?? []);
$this->syncNames($movie, Country::class, 'countries', $data['countries'] ?? []);
$this->syncNames($movie, Language::class, 'languages', $data['languages'] ?? []);
return $movie->refresh();
});
}
protected function syncNames(Movie $movie, string $modelClass, string $relation, array $names): void
{
if (!is_array($names)) return;
$ids = collect($names)
->filter()
->map(fn($n) => $this->normalizeName((string)$n))
->filter(fn($n) => $n !== '')
->map(function ($normalized) use ($modelClass) {
$existing = $modelClass::query()
->whereRaw('lower(name) = ?', [mb_strtolower($normalized)])
->first();
if ($existing) return $existing->getKey();
$m = new $modelClass();
$m->setAttribute('name', $normalized);
$m->save();
return $m->getKey();
})
->values()
->all();
if (method_exists($movie, $relation)) {
$movie->{$relation}()->sync($ids);
}
}
protected function normalizeName(string $name): string
{
return trim(preg_replace('/\s+/u', ' ', $name) ?? '');
}
}

View file

@ -0,0 +1,118 @@
<?php
namespace App\Modules\Movies\Services;
use App\Modules\Movies\Models\{Movie, Genre, Actor, Director, Studio, Country, Language};
use Illuminate\Support\Facades\DB;
use App\Modules\Movies\Services\Contracts\UpsertMovieServiceInterface;
class UpsertMovieFromProvider implements UpsertMovieServiceInterface
{
/**
* Persist a movie and all relations from normalized provider details.
*
* @param array $details Output of MovieProvider::details
* @param string $mode 'overwrite'|'duplicate'
* @return Movie
*/
public function handle(array $details, string $mode = 'overwrite'): Movie
{
return DB::transaction(function () use ($details, $mode) {
$provider = $details['provider'] ?? null;
$providerId = $details['provider_id'] ?? null;
$movie = null;
if ($mode === 'overwrite' && $provider && $providerId) {
$movie = Movie::query()
->where('provider', $provider)
->where('provider_id', $providerId)
->first();
}
if (!$movie) {
$movie = new Movie();
}
// Fill scalar fields
$movie->fill([
'provider' => $provider,
'provider_id' => $providerId,
'external_ids' => $details['external_ids'] ?? null,
'title' => $details['title'] ?? '',
'original_title' => $details['original_title'] ?? null,
'description' => $details['description'] ?? null,
'poster_url' => $details['poster_url'] ?? null,
'backdrop_url' => $details['backdrop_url'] ?? null,
'rating' => $details['rating'] ?? null,
'release_date' => $details['release_date'] ?? null,
'year' => $details['year'] ?? null,
'runtime' => $details['runtime'] ?? null,
]);
$movie->save();
// Sync relations using names from details payload
$this->syncByNames($movie, Genre::class, 'genres', $details['genres'] ?? []);
$this->syncByNames($movie, Actor::class, 'actors', $details['actors'] ?? []);
$this->syncByNames($movie, Director::class, 'directors', $details['directors'] ?? []);
$this->syncByNames($movie, Studio::class, 'studios', $details['studios'] ?? []);
$this->syncByNames($movie, Country::class, 'countries', $details['countries'] ?? []);
$this->syncByNames($movie, Language::class, 'languages', $details['languages'] ?? []);
return $movie->refresh();
});
}
/**
* @param Movie $movie
* @param class-string $modelClass
* @param string $relation
* @param array $names Names to sync
*/
protected function syncByNames(Movie $movie, string $modelClass, string $relation, array $names): void
{
// Create or fetch IDs by name (case-insensitive normalization)
$ids = collect($names)
->filter()
->map(function ($name) use ($modelClass) {
$original = (string) $name;
$normalized = $this->normalizeName($original);
if ($normalized === '') return null;
// Find existing row in a case-insensitive way
/** @var \Illuminate\Database\Eloquent\Model|null $existing */
$existing = $modelClass::query()
->whereRaw('lower(name) = ?', [mb_strtolower($normalized)])
->first();
if ($existing) {
return $existing->getKey();
}
/** @var \Illuminate\Database\Eloquent\Model $model */
$model = new $modelClass();
$model->setAttribute('name', $normalized);
$model->save();
return $model->getKey();
})
->filter()
->values()
->all();
// Sync relation if method exists
if (method_exists($movie, $relation)) {
$movie->{$relation}()->sync($ids);
}
}
/**
* Normalize a person/genre/studio/etc name for de-duplication.
*/
protected function normalizeName(string $name): string
{
// Trim, collapse internal whitespace to single spaces
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
// Leave original casing (providers) but comparisons will be lowercase.
return $name;
}
}

View file

@ -0,0 +1,26 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Movies\Http\Controllers\Admin\SearchMoviesController;
use App\Modules\Movies\Http\Controllers\Admin\AcceptMovieController;
use App\Modules\Movies\Http\Controllers\Admin\MoviesPageController;
use App\Modules\Movies\Http\Controllers\Admin\MoviesListPageController;
use App\Modules\Movies\Http\Controllers\Admin\ExistsMovieController;
use App\Modules\Movies\Http\Controllers\Admin\EditMoviePageController;
use App\Modules\Movies\Http\Controllers\Admin\UpdateMovieController;
use App\Modules\Movies\Http\Controllers\Admin\DeleteMovieController;
// Admin Movies list page (shows local DB movies)
Route::get('/', [MoviesListPageController::class, '__invoke'])->name('index');
// Admin Add Movie page (OMDb search + accept flow)
Route::get('add', [MoviesPageController::class, '__invoke'])->name('add');
Route::get('search', [SearchMoviesController::class, '__invoke'])->name('search');
Route::post('accept', [AcceptMovieController::class, 'store'])->name('accept');
Route::get('exists', [ExistsMovieController::class, '__invoke'])->name('exists');
// Admin edit/update/delete
Route::get('{movie}/edit', [EditMoviePageController::class, '__invoke'])->name('edit');
Route::patch('{movie}', [UpdateMovieController::class, 'update'])->name('update');
Route::delete('{movie}', [DeleteMovieController::class, 'destroy'])->name('destroy');

View file

@ -0,0 +1,9 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Movies\Http\Controllers\Browse\MoviesIndexController;
use App\Modules\Movies\Http\Controllers\Browse\MovieShowController;
// Public JSON API for movies
Route::get('/', [MoviesIndexController::class, '__invoke'])->name('index');
Route::get('{movie}', [MovieShowController::class, '__invoke'])->name('show');

View file

@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Movies\Http\Controllers\Browse\Entities\ActorShowController;
Route::get('{actor}', [ActorShowController::class, '__invoke'])->name('show');

View file

@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Movies\Http\Controllers\Browse\Entities\DirectorShowController;
Route::get('{director}', [DirectorShowController::class, '__invoke'])->name('show');

View file

@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Movies\Http\Controllers\Browse\Entities\GenreShowController;
Route::get('{genre}', [GenreShowController::class, '__invoke'])->name('show');

View file

@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Movies\Http\Controllers\Browse\Entities\StudioShowController;
Route::get('{studio}', [StudioShowController::class, '__invoke'])->name('show');

View file

@ -0,0 +1,9 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Movies\Http\Controllers\Pages\PublicMoviesPageController;
use App\Modules\Movies\Http\Controllers\Pages\PublicMovieShowPageController;
// Public Inertia pages
Route::get('/', [PublicMoviesPageController::class, '__invoke'])->name('index');
Route::get('{movie}', [PublicMovieShowPageController::class, '__invoke'])->name('show');

View file

@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Movies\Http\Controllers\Pages\ActorsShowPageController;
Route::get('{actor}', [ActorsShowPageController::class, '__invoke'])->name('show');

View file

@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Movies\Http\Controllers\Pages\DirectorsShowPageController;
Route::get('{director}', [DirectorsShowPageController::class, '__invoke'])->name('show');

View file

@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Movies\Http\Controllers\Pages\GenresShowPageController;
Route::get('{genre}', [GenresShowPageController::class, '__invoke'])->name('show');

View file

@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Movies\Http\Controllers\Pages\StudiosShowPageController;
Route::get('{studio}', [StudiosShowPageController::class, '__invoke'])->name('show');

View file

@ -0,0 +1,8 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Movies\Http\Controllers\Browse\MoviesIndexController;
use App\Modules\Movies\Http\Controllers\Browse\MovieShowController;
Route::get('/', [MoviesIndexController::class, '__invoke'])->name('index');
Route::get('{movie}', [MovieShowController::class, '__invoke'])->name('show');

View file

@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View file

@ -0,0 +1,91 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureActions();
$this->configureViews();
$this->configureRateLimiting();
}
/**
* Configure Fortify actions.
*/
private function configureActions(): void
{
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::createUsersUsing(CreateNewUser::class);
}
/**
* Configure Fortify views.
*/
private function configureViews(): void
{
Fortify::loginView(fn (Request $request) => Inertia::render('auth/Login', [
'canResetPassword' => Features::enabled(Features::resetPasswords()),
'canRegister' => Features::enabled(Features::registration()),
'status' => $request->session()->get('status'),
]));
Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/ResetPassword', [
'email' => $request->email,
'token' => $request->route('token'),
]));
Fortify::requestPasswordResetLinkView(fn (Request $request) => Inertia::render('auth/ForgotPassword', [
'status' => $request->session()->get('status'),
]));
Fortify::verifyEmailView(fn (Request $request) => Inertia::render('auth/VerifyEmail', [
'status' => $request->session()->get('status'),
]));
Fortify::registerView(fn () => Inertia::render('auth/Register'));
Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/TwoFactorChallenge'));
Fortify::confirmPasswordView(fn () => Inertia::render('auth/ConfirmPassword'));
}
/**
* Configure rate limiting.
*/
private function configureRateLimiting(): void
{
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
}
}

18
artisan Executable file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

30
bootstrap/app.php Normal file
View file

@ -0,0 +1,30 @@
<?php
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withProviders([
App\Modules\Movies\Providers\MoviesServiceProvider::class,
])
->withMiddleware(function (Middleware $middleware): void {
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->web(append: [
HandleAppearance::class,
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();

2
bootstrap/cache/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

6
bootstrap/providers.php Normal file
View file

@ -0,0 +1,6 @@
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
];

19
components.json Normal file
View file

@ -0,0 +1,19 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "default",
"tailwind": {
"config": "tailwind.config.js",
"css": "resources/css/app.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}

100
composer.json Normal file
View file

@ -0,0 +1,100 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/vue-starter-kit",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": [
"laravel",
"framework"
],
"license": "MIT",
"require": {
"php": "^8.2",
"inertiajs/inertia-laravel": "^2.0",
"laravel/fortify": "^1.30",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1",
"laravel/wayfinder": "^0.1.9"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/boost": "^1.8",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^4.1",
"pestphp/pest-plugin-laravel": "^4.0"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"dev:ssr": [
"npm run build:ssr",
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
"@php artisan boost:update --ansi"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

9961
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

126
config/app.php Normal file
View file

@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'America/Chicago',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

115
config/auth.php Normal file
View file

@ -0,0 +1,115 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

117
config/cache.php Normal file
View file

@ -0,0 +1,117 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
];

183
config/database.php Normal file
View file

@ -0,0 +1,183 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

Some files were not shown because too many files have changed in this diff Show more