├── .github └── workflows │ └── laravel-pint.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── composer.lock ├── config └── junction.php ├── database └── migrations │ └── 2024_08_29_124400_create_media_temporary_uploads_table.php ├── pint.json ├── routes └── media_library.php └── src ├── Commands └── CleanMediaTemporaryUploads.php ├── Http ├── Controllers │ ├── Controller.php │ ├── Filters │ │ ├── Count.php │ │ ├── Filter.php │ │ ├── Limit.php │ │ ├── Order.php │ │ ├── Relations.php │ │ ├── Scopes.php │ │ ├── Search.php │ │ ├── WhereIn.php │ │ ├── WhereNotIn.php │ │ └── Wheres.php │ ├── Helpers │ │ └── Table.php │ ├── MediaTemporaryUploadController.php │ ├── Modifiers │ │ ├── Appends.php │ │ ├── HiddenFields.php │ │ └── Modifier.php │ ├── Requests │ │ └── DefaultFormRequest.php │ ├── Resources │ │ └── BaseResource.php │ ├── Response │ │ ├── Item.php │ │ ├── Items.php │ │ └── Response.php │ ├── Traits │ │ ├── HasAction.php │ │ ├── HasDefaultAppends.php │ │ ├── HasDestroy.php │ │ ├── HasIndex.php │ │ ├── HasMedia.php │ │ ├── HasShow.php │ │ ├── HasStore.php │ │ └── HasUpdate.php │ └── Validators │ │ ├── Appends.php │ │ ├── Relations.php │ │ └── Scopes.php └── Utilities │ └── MediaFile.php ├── Junction.php ├── JunctionServiceProvider.php ├── Models └── MediaTemporaryUpload.php └── ResourceRegistrar.php /.github/workflows/laravel-pint.yml: -------------------------------------------------------------------------------- 1 | name: Laravel pint 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | laravel-pint: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: "laravel-pint" 12 | uses: aglipanci/laravel-pint-action@latest 13 | with: 14 | testMode: true 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | /vendor 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | ## v0.3.1 6 | - Fixed bug where eager loads in accessors would only work if a closure was given. 7 | 8 | ## v0.3.0 9 | - Added support for eager loads in accessors. 10 | - Relations of dot-notated *appends* are now eager loaded. 11 | 12 | ## v0.2.3 13 | - Laravel 12 support. 14 | 15 | ## v0.2.2 16 | - Fixed bug where isValidMediaFileArray in the DefaultFormRequest could throw an error if the value is not an array. 17 | 18 | ## v0.2.1 19 | - Fixed bug where prepareForValidation in the DefaultFormRequest could overwrite previous changes made to the input. 20 | 21 | ## v0.2.0 22 | - Fixed a bug where the S3 disk was not supported for temporary media uploads. 23 | - Added ability to enforce an order by model key on the query in an index route. 24 | - Added missing `ext-pdo` requirement in the composer config. 25 | - Removed table of contents from `README.md` because GitHub has built-in feature for this. 26 | - Added link to js-junction package in `README.md`. 27 | - Added laravel pint github workflow. 28 | 29 | ## v0.1.2 30 | - Fixed bug where `morphTo` relations in `where`, `whereIn`, `whereNotIn` and `search` filters would throw an error. 31 | - Deprecated `getRelationTableName` method on `Weap\Junction\Http\Controllers\Helpers\Table` class because it gives the wrong results for `morphTo` relations. 32 | 33 | ## v0.1.1 34 | - Media temporary upload `beforeMediaUpload` & `afterMediaUpload` hooks. 35 | - Media temporary upload bugfix, `$mediaFiles` was not being filled. 36 | 37 | ## v0.1.0 38 | - Add local development instructions for composer and docker. 39 | - Refactor scope calls to be more DRY. 40 | - Fix checking if an attribute exists. 41 | - Create a hook for the controller to mutate search values (e.g. for date formatting) (https://hitower.atlassian.net/browse/WEAP-187). 42 | - Print any invalid relation names in the exception. 43 | - Laravel Pint integrated. 44 | - Added the Temporary Media Upload functionality. 45 | 46 | ## v0.0.15 47 | - Return only the pagination keys if the request is paginated. 48 | 49 | ## v0.0.14 50 | - Added support for simple pagination. 51 | 52 | ## v0.0.13 53 | - Duplicate route names bug resolved. 54 | - Laravel 11 support. 55 | 56 | ## v0.0.12 57 | - Added route registrar. 58 | - Search columns bugfix. 59 | 60 | ## v0.0.11 61 | - Added support for post requests. 62 | - Updated the routing, works with only controller names now. 63 | 64 | ## v0.0.10 65 | - Fixed license in composer file. 66 | 67 | ## v0.0.9 68 | - Added license file. 69 | 70 | ## v0.0.8 71 | - Added option to save fillable instead of validated attributes. 72 | 73 | ## v0.0.7 74 | - Fixed PHPDoc. 75 | - Fixed readme example for scopes. 76 | 77 | ## v0.0.6 78 | - Count class bugfix. 79 | - Added support for whereNotIn. 80 | 81 | ## v0.0.5 82 | - Fixed a bug with the where statement. 83 | 84 | ## v0.0.4 85 | - Fixed bug where you couldn't use a scope without a parameter. 86 | 87 | ## v0.0.1 88 | - Initial version. 89 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) WEAP informatie@weap.nl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel-Junction 2 | 3 | This project allows you to easily create a REST API with Laravel. It has extended functionality, such as eager loading, searching, filtering, and more. 4 | 5 | ## Installation 6 | ```bash 7 | composer require weapnl/laravel-junction 8 | ``` 9 | 10 | ### JS/TS Support 11 | Laravel-Junction has a companion JavaScript/TypeScript package called [JS-Junction](https://github.com/weapnl/js-junction)! This package extends the functionality of our Laravel package to the front end, offering a seamless integration for your web applications. 12 | 13 | ### Development 14 | In order to easily work on this package locally and use it in another local project, do the following: 15 | 16 | 1. Add a repository in the `composer.json` file of the project you want to include Laravel-Junction in: 17 | 18 | ```json 19 | "repositories": { 20 | "laravel-junction": { 21 | "type": "path", 22 | "url": "./laravel-junction", 23 | "options": { 24 | "symlink": true 25 | } 26 | } 27 | } 28 | ``` 29 | 30 | 2. If your other project runs in docker, add a volume referencing the folder where Laravel-Junction resides: 31 | 32 | ```yaml 33 | services: 34 | api: 35 | image: ... 36 | volumes: 37 | - ../laravel-junction:/var/www/laravel-junction 38 | ``` 39 | 40 | 3. Install the local package. The `symlink` option you set on the repository earlier makes sure that you only need to do this once as opposed to every code change. 41 | 42 | ```bash 43 | composer require weapnl/laravel-junction dev-main 44 | ``` 45 | 46 | 47 | ## Quick Start 48 | ```php 49 | // app/Http/Controllers/Api/UserController.php 50 | namespace App\Http\Controllers\Api; 51 | 52 | use Weap\Junction\Http\Controllers\Controller; 53 | 54 | class UserController extends Controller 55 | { 56 | /** 57 | * The class name of the model for which the controller should implement CRUD actions. 58 | * 59 | * @var string 60 | */ 61 | public $model = User::class; 62 | 63 | /** 64 | * Define the relations which can be loaded in a request using "array" notation. 65 | * 66 | * @return array 67 | */ 68 | public function relations(): array 69 | { 70 | return [ 71 | 'orders', 72 | ]; 73 | } 74 | ``` 75 | 76 | ```php 77 | // routes/api.php 78 | Junction::apiResource('users', 'UserController'); 79 | ``` 80 | 81 | You're all set and ready to go now. You can now perform requests to the `/api/users` endpoint. Try a post request to create a new user, or a get request to retrieve all users. 82 | 83 | ## Usage 84 | 85 | ### Setting up the Controller 86 | 87 | To make the controller accessible through the api, you need to extend the `Weap\Junction\Http\Controllers\Controller` class. This class extends the default Laravel controller, and adds some extra functionality. 88 | Defining the controller is pretty straightforward, check the [Quick start](#quick-start) section for a basic example. We will now go over some of the extra functionality. 89 | 90 | ```php 91 | // app/Http/Controllers/Api/UserController.php 92 | namespace App\Http\Controllers\Api; 93 | 94 | use Weap\Junction\Http\Controllers\Controller; // Make sure to import the Controller class from the Weap/Junction package. 95 | 96 | class UserController extends Controller 97 | { 98 | /** 99 | * The class name of the model for which the controller should implement CRUD actions. 100 | * 101 | * @var string 102 | */ 103 | public $model = User::class; 104 | 105 | /** 106 | * The class name of Resource to be used for the show and index methods. 107 | * 108 | * @var string $resource 109 | */ 110 | public $resource = UserResource::class; 111 | 112 | /** 113 | * Define the relations which can be loaded in a request using "array" notation. 114 | * 115 | * @return array 116 | */ 117 | public function relations(): array 118 | { 119 | return [ 120 | 'orders', 121 | // Define all your relations here with should be accessible through the API. 122 | ]; 123 | } 124 | ``` 125 | 126 | 127 | #### Sample usage 128 | ``` 129 | /api/users?orders[0][column]=id&orders[0][direction]=asc&&search_value=john&search_columns[]=name&search_columns[]=email 130 | ``` 131 | 132 | #### Sample response 133 | The response always contains the properties `items`, `total` and `page`, even if you're not using pagination. 134 | ```json 135 | { 136 | "items": [ 137 | { 138 | "id": 2, 139 | "name": "John Doe", 140 | "email": "john.doe@app.com", 141 | "orders": [], 142 | "comments": [ 143 | { 144 | "id": 1, 145 | "body": "Hello world!" 146 | } 147 | ] 148 | } 149 | ], 150 | "total": 1, // Total amount of items 151 | "page": 1 // The current page 152 | } 153 | ``` 154 | 155 | #### Filters 156 | Filters are applied to the query. Filters are defined using array keys. Available filters: 157 | 158 | | Key | Example | Description | 159 | |------------------|----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| 160 | | `limit` | `limit=10` | Limit the maximum amount of results. | 161 | | `orders` | `orders[][column]=name,orders[][direction]=asc` | Columns to order on. | 162 | | `with` | `with=[orders,comments]` | Relations to load. | 163 | | `scopes` | `scopes[0][name]=hasName&scopes[0][params][0]=John` | Scopes to apply with the given parameters. | 164 | | `search_value` | `search_value=john` | Search for the given value. | 165 | | `search_columns` | `search_columns[]=id&search_columns[]=name` | The columns to search in. (optional: defaults to the searchable variable on your controller.) | 166 | | `wheres` | `wheres[0][column]=name&wheres[0][operator]=%3D&wheres[0][value]=John (%3D = '=', ASCII Encoding)` | Apply where clauses. | 167 | | `where_in` | `where_in[0][column]=id&where_in[0][values][0]=1&where_in[0][values][1]=2` | Apply where in clause. (Where id is 1 or 2) | 168 | | `where_not_in` | `where_not_in[0][column]=id&where_not_in[0][values][0]=1&where_not_in[0][values][1]=2` | Apply where not in clause. (Where id is not 1 or 2) | 169 | 170 | #### Modifiers 171 | Modifiers are applied after the query has run. Available modifiers: 172 | 173 | | Key | Example | Description | 174 | |-----------------|----------------------------------------------|-----------------------------------------------------------------------------------------------------------| 175 | | `appends` | `appends[]=fullname&appends[]=identity.age` | Add appends to each model in the result. | 176 | | `hidden_fields` | `hidden_fields[]=id&hidden_fields[]=address` | Hide the given fields for each model in the result. | 177 | | `pluck` | `pluck[]=id&pluck[]=address.house_number` | Only return the given fields for each model in the result. (Only available for `index` and `show` routes) | 178 | 179 | #### Pagination 180 | Pagination is applied on database-level (after applying all filters). The following parameters can be used to setup pagination: 181 | 182 | | Key | Example | Description | 183 | |---------------|-----------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------| 184 | | `paginate` | `paginate=25` | Paginate the result. This also specifies the amount of items per page. | 185 | | `page` | `page=1` | The page to get. Defaults to 1. Requires `paginate` to be set. | 186 | | `page_for_id` | `page_for_id=1` | This will search the correct page based on the given model id. `page` is used as a fallback if the given id can not be found. Requires `paginate` to be set. | 187 | 188 | #### Simple pagination 189 | Simple pagination almost the same as the pagination above. But the simple pagination doesn't return the total amount of items or a page number. This is useful for large database tables where the normal pagination is too slow. 190 | 191 | | Key | Example | Description | 192 | |---------------------|--------------------------|-----------------------------------------------------| 193 | | `paginate` | `paginate=25` | This specifies the amount of items per page. | 194 | | `simple_pagination` | `simple_pagination=true` | This defines that simple pagination should be used. | 195 | 196 | ### Relations 197 | To limit the relations which can be loaded using the `with` filter, you can override the `relations` method on your controller. 198 | This method should return an array containing relations (dot-notation is supported). To add filters to the relation query, you can use the key as relation name and a closure as the value. 199 | 200 | **Note**: When using dot-notation, if a closure is given for one of the higher-level relations in your controller, that closure will be applied to the query. For example with relations implemented like below, loading the relation `user.activities`, will apply the `isAdmin` scope to the user query. 201 | ```php 202 | public function relations() 203 | { 204 | return [ 205 | 'user' => fn ($query) => $query->isAdmin(), 206 | 'user.activities', 207 | ]; 208 | } 209 | ``` 210 | 211 | ### Accessors 212 | To append accessors to models in the response, you can use the `appends` modifier. This modifier allows dot-notation for relations. These relations will be eager loaded (relation closures defined in the controller are applied as well). 213 | 214 | In some cases you may want an accessor to return a value of one of its relations, which would normally cause a lazy load when the accessor is executed. To eager load the relation, you could add it to the request using the `with` filter, which means the frontend is responsible for proper eager loading. 215 | To shift this responsibility to the backend, you can use the `Junction::makeAttribute()` function to make an accessor instead of `Attribute::make()`: 216 | 217 | ```php 218 | use Weap\Junction\Junction; 219 | 220 | /** 221 | * @return Attribute 222 | */ 223 | protected function name(): Attribute 224 | { 225 | return Junction::makeAttribute( 226 | get: fn (): string => $this->contact->name, 227 | with: ['contact'], 228 | ); 229 | } 230 | ``` 231 | 232 | In this case, the `contact` relation would be eager loaded before the accessor is executed, which can prevent N+1 queries. The `with` array also allows dot-notation and closures to customize the query: 233 | 234 | ```php 235 | use Weap\Junction\Junction; 236 | 237 | /** 238 | * @return Attribute 239 | */ 240 | protected function name(): Attribute 241 | { 242 | return Junction::makeAttribute( 243 | get: function (): string { 244 | return match ($this->subjectable::class) { 245 | Employee::class => $this->subjectable->contact?->name, 246 | Freelancer::class => $this->subjectable->company?->name, 247 | default => null, 248 | } ?: ''; 249 | }, 250 | with: [ 251 | 'subjectable' => function ($query) { 252 | $query->morphWith([ 253 | Employee::class => ['contact'], 254 | Freelancer::class => ['company'], 255 | ]); 256 | }, 257 | ], 258 | ); 259 | } 260 | ``` 261 | 262 | ### Search 263 | This package supports search functionality for given models and relations. 264 | On your controller, add a searchable property like defined below. 265 | When you want to search a model, add "search_value" to your request. Optionally you can add "search_columns" to override the columns from your controller. 266 | ```php 267 | public $searchable = [ 268 | 'id', 269 | 'name', 270 | 'orders.order_number', 271 | ]; 272 | ``` 273 | 274 | ### Resources 275 | To use resources, set the `resource` variable in your controller. Your resource must extend `\Weap\Junction\Http\Controllers\Resources`. 276 | 277 | This allows you to specify which attributes, accessors and relations will be returned. To do this, override the corresponding method: 278 | - `availableAttributes`. Return an array of strings, specifying which attributes will be returned. The primary key is always included. 279 | - `availableAccessors`. Return an array of strings, specifying which accessors will be returned. 280 | - `availableRelations`. Return an array of key/value pairs, where the key is the name of the relation, and the value is another resource. 281 | 282 | Return `null` in any of these methods to allow `ALL` attributes/accessors/relations to be returned. 283 | 284 | Example: 285 | ```php 286 | class UserResource extends BaseResource 287 | { 288 | /** 289 | * @return array|null 290 | */ 291 | protected function availableAttributes(): ?array 292 | { 293 | return [ 294 | 'first_name' 295 | ]; 296 | } 297 | 298 | /** 299 | * @return array|null 300 | */ 301 | protected function availableAccessors(): ?array 302 | { 303 | return [ 304 | 'fullName' 305 | ]; 306 | } 307 | 308 | /** 309 | * @return array|null 310 | */ 311 | protected function availableRelations(): ?array 312 | { 313 | return [ 314 | 'orders' => OrderResource::class, 315 | ]; 316 | } 317 | } 318 | ``` 319 | ### Actions 320 | This package also supports action routes. 321 | 322 | Add the action method in your controller: 323 | 324 | ```php 325 | /** 326 | * @param null|Model $model 327 | */ 328 | protected function actionSomeName($model = null) 329 | { 330 | // 331 | } 332 | ``` 333 | 334 | - If you are using policies, your policy should implement the `action` policy, which receives the model as parameter. 335 | - Now, you can call the following route as a `PUT` request: `/api/users`. In the body, add the following (the id is optional): 336 | ```json 337 | { 338 | "action": "someName", 339 | "id": 1 340 | } 341 | ``` 342 | - You can add as many actions as you want. Just make sure to prefix the method with `action`. 343 | 344 | ### Index route options 345 | #### Enforce order by model key 346 | To enable the option to enforce an order by on the query, set the `junction.route.index.enforce_order_by_model_key` config value to `true`. 347 | This will add an "order by" clause to the query based on the model's key name, if no "order by" is already present for the key name. 348 | The default order direction is `asc`, but if you want it to use `desc`, update the `junction.route.index.enforce_order_by_model_key_direction` config value to `desc`. 349 | 350 | ### Validation 351 | 352 | #### FormRequest validation 353 | To validate the incoming request, you can create a `FormRequest` and extend the `Weap\Junction\Http\Controllers\Requests\DefaultFormRequest` class. This class extends the default Laravel `FormRequest` class, and adds some extra functionality. 354 | 355 | #### Standard validation 356 | To validate the request, create a request file for your model and add this to the controller. 357 | ```php 358 | /** 359 | * The class name of FormRequest to be used for the store and update methods. 360 | * 361 | * @var string 362 | */ 363 | public $formRequest = ModelRequest::class; 364 | ``` 365 | 366 | - Add rules to the `rules()` method in the `ModelRequest`. 367 | ```php 368 | /** 369 | * Get the validation rules that apply to the request. 370 | * 371 | * @return array 372 | */ 373 | public function rules() 374 | { 375 | return [ 376 | 'first_name' => 'required', 377 | ]; 378 | } 379 | 380 | /** 381 | * Define validation rule messages for store and update requests. 382 | * 383 | * @return array 384 | */ 385 | public function messages() 386 | { 387 | return [ 388 | 'first_name.required' => 'The first name is required.', 389 | ]; 390 | } 391 | ``` 392 | 393 | #### Save fillable attributes 394 | By default, only validated attributes are saved when calling store/update routes. To save fillable attributes instead, set the following on your controller: 395 | ```php 396 | /** 397 | * Set to true to save fillable instead of validated attributes in store/update methods. 398 | * 399 | * @var bool 400 | */ 401 | protected $saveFillable = true; 402 | ``` 403 | 404 | ### Using Temporary Media Files with [Spatie Medialibrary](https://spatie.be/docs/laravel-medialibrary/v11/introduction) 405 | 406 | #### Step 1: Uploading Files via the API 407 | To upload files through the API, use the `/media/upload` endpoint. Include an array of files under the `files` key in the request body. These files will be temporarily stored in the media library and linked to a `MediaTemporaryUpload` model. 408 | 409 | **Example Upload Request:** 410 | ```json 411 | { 412 | "files": [, ...] 413 | } 414 | ``` 415 | 416 | #### Step 2: Attaching Files to a Model 417 | 418 | ##### Example A: Updating an Existing Model 419 | To attach files to an existing model, include the media IDs in a `PUT` request. For instance, if you want to attach an identity document to an employee, your `PUT` request to `/employees/3` might look like this, assuming the identity files are stored in the `IdentityFiles` [collection](https://spatie.be/docs/laravel-medialibrary/v11/working-with-media-collections/simple-media-collections) on the `Employee` model: 420 | 421 | ```json 422 | { 423 | "first_name": "John", 424 | "last_name": "Doe", 425 | "_media": { 426 | "IdentityFiles": [1] 427 | } 428 | } 429 | ``` 430 | 431 | The API will search the request body for the `_media` key and associate the specified media IDs with the correct collection. In this example, media ID 1 will be attached to the `IdentityFiles` collection of the `Employee` with ID 3. 432 | 433 | ##### Example B: Creating a New Model 434 | You can also attach files when creating a new model using a `POST` request. For example, if you are creating a new `Employee` and want to attach a profile picture, your `POST` request to `/employees` might look like this: 435 | 436 | ```json 437 | { 438 | "first_name": "Jane", 439 | "last_name": "Smith", 440 | "_media": { 441 | "ProfilePicture": [2] 442 | } 443 | } 444 | ``` 445 | 446 | This will attach media ID 2 to the `ProfilePicture` collection of the newly created `Employee`. 447 | 448 | #### Using the `_media` Key in Nested Structures 449 | The `_media` key can also be nested within the request body, for example, inside a `contact` key. In this case, the API will attach the media files to the `contact` relationship of the `Employee`, whether you are creating a new model or updating an existing one. 450 | 451 | **Example Request with Nested `_media` Key (for creation):** 452 | ```json 453 | { 454 | "first_name": "Jane", 455 | "last_name": "Smith", 456 | "contact": { 457 | "phone": "123-456-7890", 458 | "_media": { 459 | "ProfilePicture": [3] 460 | } 461 | } 462 | } 463 | ``` 464 | 465 | In this example, when creating a new `Employee`, media ID 3 will be attached to the `ProfilePicture` collection within the `contact` relationship of the new `Employee`. 466 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "weapnl/laravel-junction", 3 | "description": "Easily create a REST API with extended functionality, such as eager loading, searching, filtering, and more.", 4 | "homepage": "https://github.com/weapnl/laravel-junction", 5 | "license": "MIT", 6 | "type": "library", 7 | "authors": [ 8 | { 9 | "name": "Robin", 10 | "email": "robin@weap.nl" 11 | } 12 | ], 13 | "suggest": { 14 | "spatie/laravel-medialibrary": "For using the temporary media upload functionality." 15 | }, 16 | "minimum-stability": "dev", 17 | "config": { 18 | "optimize-autoloader": true, 19 | "preferred-install": "dist", 20 | "sort-packages": true 21 | }, 22 | "prefer-stable": true, 23 | "require": { 24 | "php": "^8.2", 25 | "ext-pdo": "*", 26 | "laravel/framework": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0" 27 | }, 28 | "extra": { 29 | "laravel": { 30 | "providers": [ 31 | "Weap\\Junction\\JunctionServiceProvider" 32 | ] 33 | } 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Weap\\Junction\\": "src/" 38 | } 39 | }, 40 | "require-dev": { 41 | "laravel/pint": "^1.17" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /config/junction.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'index' => [ 6 | /* 7 | * Always add an "order by" clause based on the model key name to the index query, if the query doesn't already have one. 8 | */ 9 | 'enforce_order_by_model_key' => false, 10 | 11 | /* 12 | * Change the direction of the enforced "order by" of the model key. 13 | * 14 | * Possible values: "asc" or "desc". 15 | */ 16 | 'enforce_order_by_model_key_direction' => 'asc', 17 | ], 18 | 19 | 'media' => [ 20 | /* 21 | * To enable the media upload endpoint, set this variable to true. 22 | * You need to have the spatie/media-library package installed, for this to work. 23 | */ 24 | 'enabled' => true, 25 | 26 | /* 27 | * To add a custom middleware around the media upload endpoint. 28 | */ 29 | 'middleware' => ['api'], 30 | 31 | /* 32 | * To prefix the media upload endpoint. 33 | */ 34 | 'prefix' => '', 35 | ], 36 | ], 37 | ]; 38 | -------------------------------------------------------------------------------- /database/migrations/2024_08_29_124400_create_media_temporary_uploads_table.php: -------------------------------------------------------------------------------- 1 | id(); 17 | $table->foreignId('created_by_user_id')->index()->constrained(app(config('auth.providers.users.model'))->getTable())->cascadeOnDelete(); 18 | $table->timestamps(); 19 | }); 20 | } 21 | 22 | /** 23 | * Reverse the migrations. 24 | * 25 | * @return void 26 | */ 27 | public function down() 28 | { 29 | Schema::dropIfExists('media_temporary_uploads'); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "psr12", 3 | "rules": { 4 | "array_indentation": true, 5 | "blank_lines_before_namespace": true, 6 | "cast_spaces": true, 7 | "clean_namespace": true, 8 | "concat_space": { 9 | "spacing": "one" 10 | }, 11 | "global_namespace_import": { 12 | "import_functions": false 13 | }, 14 | "method_chaining_indentation": true, 15 | "native_function_invocation": { 16 | "include": [] 17 | }, 18 | "new_with_braces": { 19 | "anonymous_class": false, 20 | "named_class": true 21 | }, 22 | "no_blank_lines_after_phpdoc": true, 23 | "no_break_comment": false, 24 | "no_short_bool_cast": true, 25 | "not_operator_with_successor_space": true, 26 | "ordered_imports": { 27 | "sort_algorithm": "alpha" 28 | }, 29 | "phpdoc_add_missing_param_annotation": { 30 | "only_untyped": false 31 | }, 32 | "phpdoc_indent": true, 33 | "phpdoc_inline_tag_normalizer": true, 34 | "phpdoc_no_access": true, 35 | "phpdoc_no_package": true, 36 | "phpdoc_no_useless_inheritdoc": true, 37 | "phpdoc_order": { 38 | "order": [ 39 | "param", 40 | "return", 41 | "throws" 42 | ] 43 | }, 44 | "phpdoc_param_order": true, 45 | "phpdoc_scalar": true, 46 | "phpdoc_separation": { 47 | "groups": [ 48 | [ 49 | "deprecated", 50 | "link", 51 | "see", 52 | "since" 53 | ], 54 | [ 55 | "author", 56 | "copyright", 57 | "license" 58 | ], 59 | [ 60 | "category", 61 | "package", 62 | "subpackage" 63 | ], 64 | [ 65 | "property", 66 | "property-read", 67 | "property-write" 68 | ], 69 | [ 70 | "param", 71 | "return" 72 | ] 73 | ] 74 | }, 75 | "phpdoc_single_line_var_spacing": true, 76 | "phpdoc_trim": true, 77 | "phpdoc_types": true, 78 | "phpdoc_types_order": { 79 | "null_adjustment": "always_last", 80 | "sort_algorithm": "none" 81 | }, 82 | "phpdoc_var_without_name": true, 83 | "single_line_after_imports": true, 84 | "single_space_around_construct": true, 85 | "single_quote": { 86 | "strings_containing_single_quote_chars": true 87 | }, 88 | "single_trait_insert_per_statement": false, 89 | "trailing_comma_in_multiline": true, 90 | "whitespace_after_comma_in_array": true 91 | } 92 | } 93 | 94 | -------------------------------------------------------------------------------- /routes/media_library.php: -------------------------------------------------------------------------------- 1 | argument('hours'); 31 | $cutOffDate = Carbon::now()->subHours((int) $maxAgeInHours)->format('Y-m-d H:i:s'); 32 | 33 | $mediaTemporaryUploads = MediaTemporaryUpload::query() 34 | ->where('created_at', '<', $cutOffDate) 35 | ->get(); 36 | 37 | foreach ($mediaTemporaryUploads as $mediaTemporaryUpload) { 38 | $mediaTemporaryUpload->delete(); 39 | } 40 | 41 | $this->info("Deleted {$mediaTemporaryUploads->count()} record(s) from the media temporary uploads."); 42 | 43 | return static::SUCCESS; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Http/Controllers/Controller.php: -------------------------------------------------------------------------------- 1 | model = $this->model ?? $model; 77 | 78 | if (! $this->model) { 79 | throw new Exception('Your controller should contain a property `model` to define which model to query for.'); 80 | } 81 | } 82 | 83 | /** 84 | * Define the relations which can be loaded in a request using "array" notation. 85 | * 86 | * @return array 87 | */ 88 | public function relations() 89 | { 90 | return []; 91 | } 92 | 93 | /** 94 | * Define the searchable column which can be searched trough in a request using "array" notation. 95 | * 96 | * @return array 97 | */ 98 | public function searchable() 99 | { 100 | return []; 101 | } 102 | 103 | /** 104 | * Define validation rules for store and update requests. 105 | * 106 | * @return array 107 | */ 108 | public function rules() 109 | { 110 | return []; 111 | } 112 | 113 | /** 114 | * Define validation rule messages for store and update requests. 115 | * 116 | * @return array 117 | */ 118 | public function messages() 119 | { 120 | return []; 121 | } 122 | 123 | /** 124 | * Mutate the search value from the client for this particular model. 125 | * 126 | * @param string $searchValue The value the user searched for. 127 | * @return string The mutated search value. 128 | */ 129 | public function mutateSearchValue(string $searchValue): string 130 | { 131 | return $searchValue; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Http/Controllers/Filters/Count.php: -------------------------------------------------------------------------------- 1 | input('count'); 19 | 20 | if (! $relations || ! is_array($relations)) { 21 | return; 22 | } 23 | 24 | $query->withCount( 25 | RelationsValidator::validate($controller, $relations) 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Http/Controllers/Filters/Filter.php: -------------------------------------------------------------------------------- 1 | input('limit'); 18 | 19 | if (! $limit) { 20 | return; 21 | } 22 | 23 | $query->limit($limit); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Http/Controllers/Filters/Order.php: -------------------------------------------------------------------------------- 1 | input('orders'); 19 | 20 | if (empty($orders)) { 21 | return; 22 | } 23 | 24 | foreach ($orders as $order) { 25 | $column = $order['column'] ?? null; 26 | $direction = $order['direction'] ?? null; 27 | 28 | if ($column === null || $direction === null) { 29 | throw new RuntimeException('A "order" array must contain a column and a direction.'); 30 | } 31 | 32 | $query->orderBy($column, $direction); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Http/Controllers/Filters/Relations.php: -------------------------------------------------------------------------------- 1 | getRelations(); 24 | 25 | if (! $relations) { 26 | return; 27 | } 28 | 29 | RelationsValidator::validate($controller, $relations); 30 | 31 | $relationFilters = collect($controller->relations()) 32 | ->filter(fn ($closure) => is_callable($closure)) 33 | ->undot(); 34 | 35 | $accessors = collect(request()?->getAccessors()) 36 | ->flip() 37 | ->undot(); 38 | 39 | collect($relations) 40 | ->flip() 41 | ->undot() 42 | ->each(function ($nestedRelations, $relation) use ($query, $relationFilters, $accessors) { 43 | static::addWith( 44 | $query, 45 | $relation, 46 | is_array($nestedRelations) || is_callable($nestedRelations) ? $nestedRelations : [], 47 | $relationFilters->all(), 48 | $accessors->all(), 49 | ); 50 | }); 51 | } 52 | 53 | /** 54 | * @param Builder|Relation $query 55 | * @param string $relation 56 | * @param array|Closure $nestedRelations 57 | * @param array $relationFilters 58 | * @param array $accessors 59 | * @return void 60 | */ 61 | protected static function addWith(Builder|Relation $query, string $relation, array|Closure $nestedRelations, array $relationFilters, array $accessors): void 62 | { 63 | $query->with($relation, function (Builder|Relation $query) use ($relation, $relationFilters, $nestedRelations, $accessors) { 64 | if (is_callable($nestedRelations)) { 65 | $nestedRelations($query); 66 | } 67 | 68 | $relationFilter = $relationFilters[$relation] ?? []; 69 | 70 | if (is_callable($relationFilter)) { 71 | $relationFilter($query); 72 | } 73 | 74 | $accessors = $accessors[$relation] ?? []; 75 | 76 | foreach ($accessors as $accessor => $nestedAccessors) { 77 | if (is_array($nestedAccessors)) { 78 | continue; 79 | } 80 | 81 | $accessor = Str::camel($accessor); 82 | $attribute = method_exists($query->getModel(), $accessor) ? $query->getModel()::$accessor() : null; 83 | 84 | if ($attribute instanceof Attribute && ($with = Junction::$cachedAttributeRelations[$query->getModel()::class][$accessor] ?? null)) { 85 | $nestedRelations += Arr::mapWithKeys($with, fn ($relation, $key) => is_callable($relation) ? [$key => $relation] : [$relation => $key]); 86 | } 87 | } 88 | 89 | foreach ($nestedRelations as $nestedRelation => $nestedRelations) { 90 | static::addWith( 91 | $query, 92 | $nestedRelation, 93 | is_array($nestedRelations) || is_callable($nestedRelations) ? $nestedRelations : [], 94 | is_array($relationFilter) ? $relationFilter : [], 95 | is_array($accessors) ? $accessors : [], 96 | ); 97 | } 98 | }); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Http/Controllers/Filters/Scopes.php: -------------------------------------------------------------------------------- 1 | input('scopes'); 19 | 20 | if (! $scopes) { 21 | return; 22 | } 23 | 24 | $scopes = ScopesValidator::validate($controller, $scopes); 25 | 26 | foreach ($scopes as $scope) { 27 | $scopeName = $scope['name']; 28 | $params = $scope['params'] ?? []; 29 | $query->$scopeName(...$params); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Http/Controllers/Filters/Search.php: -------------------------------------------------------------------------------- 1 | model); 25 | 26 | $searchValue = request()->input('search_value'); 27 | $columns = empty(request()->input('search_columns')) ? $controller->searchable() : request()->input('search_columns'); 28 | 29 | if (empty($searchValue) || empty($columns)) { 30 | return; 31 | } 32 | 33 | $searchValue = $controller->mutateSearchValue($searchValue); 34 | 35 | $query->where(function (Builder $query) use ($searchValue, $model, $columns) { 36 | $columns = Arr::undot(array_flip($columns)); 37 | 38 | self::searchColumnQuery($query, $columns, $model->getTable(), $searchValue); 39 | }); 40 | } 41 | 42 | /** 43 | * @param Builder $query 44 | * @param array $columns 45 | * @param string $tableName 46 | * @param string $searchValue 47 | * @return void 48 | */ 49 | public static function searchColumnQuery(Builder $query, array $columns, string $tableName, string $searchValue): void 50 | { 51 | $connection = DB::connection()->getPdo()->getAttribute(PDO::ATTR_DRIVER_NAME); 52 | $likeOperator = $connection === 'pgsql' ? 'ILIKE' : 'LIKE'; 53 | 54 | foreach ($columns as $relationName => $relationColumns) { 55 | if (! is_array($relationColumns)) { 56 | $query->orWhere($tableName . '.' . $relationName, $likeOperator, '%' . $searchValue . '%'); 57 | } else { 58 | $relation = Table::getRelation($query->getModel()::class, [$relationName]); 59 | 60 | $query->orWhereHas($relationName, function (Builder $query) use ($relationColumns, $relation, $searchValue) { 61 | $tableName = $relation instanceof BelongsToMany ? $relation->getTable() : $query->from; 62 | 63 | $query->where(function (Builder $query) use ($relationColumns, $tableName, $searchValue) { 64 | self::searchColumnQuery($query, $relationColumns, $tableName, $searchValue); 65 | }); 66 | }); 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Http/Controllers/Filters/WhereIn.php: -------------------------------------------------------------------------------- 1 | input('where_in'); 23 | 24 | if (! $whereIns) { 25 | return; 26 | } 27 | 28 | $whereIns = (array) $whereIns; 29 | 30 | foreach ($whereIns as $whereIn) { 31 | $values = (array) ($whereIn['values'] ?? []); 32 | 33 | self::traverse($query, $whereIn['column'], $values); 34 | } 35 | } 36 | 37 | /** 38 | * @param Builder $query 39 | * @param string $column 40 | * @param array $values 41 | */ 42 | protected static function traverse(Builder $query, string $column, array $values): void 43 | { 44 | $relationParts = explode('.', $column); 45 | 46 | // Directly on the main model (no relation) 47 | if (count($relationParts) === 1) { 48 | $query->whereIn($query->getModel()->getTable() . '.' . $column, $values); 49 | 50 | return; 51 | } 52 | 53 | // Treatment for columns in a relationship 54 | $actualColumn = array_pop($relationParts); 55 | $relationPath = implode('.', $relationParts); 56 | $relation = Table::getRelation($query->getModel()::class, $relationParts); 57 | 58 | $query->whereHas($relationPath, function (Builder $subQuery) use ($actualColumn, $values, $relation) { 59 | $tableName = $relation instanceof BelongsToMany ? $relation->getTable() : $subQuery->from; 60 | $fullColumn = $tableName . '.' . $actualColumn; 61 | 62 | $subQuery->whereIn($fullColumn, $values); 63 | }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Http/Controllers/Filters/WhereNotIn.php: -------------------------------------------------------------------------------- 1 | input('where_not_in'); 23 | 24 | if (! $whereNotIns) { 25 | return; 26 | } 27 | 28 | $whereNotIns = (array) $whereNotIns; 29 | 30 | foreach ($whereNotIns as $whereNotIn) { 31 | $values = (array) ($whereNotIn['values'] ?? []); 32 | 33 | self::traverse($query, $whereNotIn['column'], $values); 34 | } 35 | } 36 | 37 | /** 38 | * @param Builder $query 39 | * @param string $column 40 | * @param array $values 41 | */ 42 | protected static function traverse(Builder $query, string $column, array $values): void 43 | { 44 | $relationParts = explode('.', $column); 45 | 46 | // Directly on the main model (no relation) 47 | if (count($relationParts) === 1) { 48 | $query->whereNotIn($query->getModel()->getTable() . '.' . $column, $values); 49 | 50 | return; 51 | } 52 | 53 | // Treatment for columns in a relationship 54 | $actualColumn = array_pop($relationParts); 55 | $relationPath = implode('.', $relationParts); 56 | $relation = Table::getRelation($query->getModel()::class, $relationParts); 57 | 58 | $query->whereHas($relationPath, function (Builder $subQuery) use ($actualColumn, $values, $relation) { 59 | $tableName = $relation instanceof BelongsToMany ? $relation->getTable() : $subQuery->from; 60 | $fullColumn = $tableName . '.' . $actualColumn; 61 | 62 | $subQuery->whereNotIn($fullColumn, $values); 63 | }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Http/Controllers/Filters/Wheres.php: -------------------------------------------------------------------------------- 1 | input('wheres'); 24 | 25 | if (! $wheres) { 26 | return; 27 | } 28 | 29 | foreach ($wheres as $where) { 30 | $column = $where['column'] ?? null; 31 | $operator = $where['operator'] ?? null; 32 | $value = $where['value'] ?? null; 33 | 34 | if ($column === null || $operator === null) { 35 | throw new RuntimeException('A "where" string must contain a column and a operator.'); 36 | } 37 | 38 | self::traverse($query, $column, $operator, $value); 39 | } 40 | } 41 | 42 | /** 43 | * @param Builder|Relation $query 44 | * @param string $column 45 | * @param string $operator 46 | * @param mixed $value 47 | */ 48 | protected static function traverse(Builder|Relation $query, string $column, string $operator, mixed $value): void 49 | { 50 | $columnParts = explode('.', $column); 51 | 52 | // If there's no relation (single column name), apply the where condition and exit early. 53 | if (count($columnParts) === 1) { 54 | static::applyWhere($query, $query->getModel()->getTable() . '.' . $column, $operator, $value); 55 | 56 | return; 57 | } 58 | 59 | $actualColumn = array_pop($columnParts); 60 | $relationPath = implode('.', $columnParts); 61 | $relation = Table::getRelation($query->getModel()::class, $columnParts); 62 | 63 | $query->whereHas($relationPath, function (Builder $innerQuery) use ($actualColumn, $operator, $value, $relation) { 64 | $tableName = $relation instanceof BelongsToMany ? $relation->getTable() : $innerQuery->from; 65 | $fullColumn = $tableName . '.' . $actualColumn; 66 | 67 | static::applyWhere($innerQuery, $fullColumn, $operator, $value); 68 | }); 69 | } 70 | 71 | /** 72 | * @param $query 73 | * @param string $column 74 | * @param string $operator 75 | * @param mixed $value 76 | * @return void 77 | */ 78 | protected static function applyWhere($query, string $column, string $operator, mixed $value): void 79 | { 80 | if ($value === null) { 81 | if (in_array($operator, ['!=', 'IS NOT'], true)) { 82 | $query->whereNotNull($column); 83 | } else { 84 | $query->whereNull($column); 85 | } 86 | 87 | return; 88 | } 89 | 90 | $query->where($column, $operator, $value); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Http/Controllers/Helpers/Table.php: -------------------------------------------------------------------------------- 1 | getTable(); 23 | } 24 | 25 | return $relation->newModelInstance()->getTable(); 26 | } 27 | 28 | /** 29 | * @param string $model 30 | * @param array $relations 31 | * @return Relation 32 | */ 33 | public static function getRelation(string $model, array $relations): Relation 34 | { 35 | if (count($relations) > 1) { 36 | $relation = array_shift($relations); 37 | 38 | return static::getRelation( 39 | (new $model())->$relation()->newModelInstance()::class, 40 | $relations 41 | ); 42 | } 43 | 44 | return (new $model())->{$relations[0]}(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Http/Controllers/MediaTemporaryUploadController.php: -------------------------------------------------------------------------------- 1 | validate([ 27 | 'files' => ['required', 'array', 'min:1'], 28 | 'files.*' => ['required', 'file'], 29 | ]); 30 | 31 | $mediaTemporaryUpload = new MediaTemporaryUpload(); 32 | $mediaTemporaryUpload->createdBy()->associate(Auth::user()); 33 | $mediaTemporaryUpload->save(); 34 | 35 | $mediaIds = []; 36 | foreach ($validated['files'] as $file) { 37 | $mediaIds[] = $mediaTemporaryUpload->addMedia($file)->toMediaCollection()->id; 38 | } 39 | 40 | return response()->json($mediaIds); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Http/Controllers/Modifiers/Appends.php: -------------------------------------------------------------------------------- 1 | getAccessors(); 21 | 22 | if (! $appends) { 23 | return; 24 | } 25 | 26 | $appends = AppendsValidator::validate($controller, $appends); 27 | 28 | $response->modify(function (Model $model) use ($appends) { 29 | self::traverse($model, $appends); 30 | }); 31 | } 32 | 33 | /** 34 | * @param Model $model 35 | * @param array $fields 36 | */ 37 | public static function traverse(Model $model, array $fields): void 38 | { 39 | foreach ($fields as $field) { 40 | $traversed = Str::of($field)->explode('.'); 41 | 42 | if ($traversed->count() > 1) { 43 | $relation = $traversed->shift(); 44 | 45 | if ($model->$relation instanceof Enumerable) { 46 | $model->$relation->each(function ($model) use ($traversed) { 47 | self::traverse($model, [$traversed->join('.')]); 48 | }); 49 | } elseif ($model->$relation instanceof Model) { 50 | self::traverse($model->$relation, [$traversed->join('.')]); 51 | } 52 | } else { 53 | if ($model instanceof Enumerable) { 54 | $model->each->append($field); 55 | } else { 56 | $model->append($field); 57 | } 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Http/Controllers/Modifiers/HiddenFields.php: -------------------------------------------------------------------------------- 1 | input('hidden_fields'); 20 | 21 | if (! $hiddenFields) { 22 | return; 23 | } 24 | 25 | $response->modify(function (Model $model) use ($hiddenFields) { 26 | self::traverse($model, $hiddenFields); 27 | }); 28 | } 29 | 30 | /** 31 | * @param Model $model 32 | * @param array $hiddenFields 33 | */ 34 | public static function traverse(Model $model, array $hiddenFields): void 35 | { 36 | foreach ($hiddenFields as $field) { 37 | $traversed = Str::of($field)->explode('.'); 38 | 39 | if ($traversed->count() > 1) { 40 | $relation = $traversed->shift(); 41 | 42 | if ($model->$relation instanceof Enumerable) { 43 | $model->$relation->each(function ($model) use ($traversed) { 44 | self::traverse($model, [$traversed->join('.')]); 45 | }); 46 | } elseif ($model->$relation instanceof Model) { 47 | self::traverse($model->$relation, [$traversed->join('.')]); 48 | } 49 | } elseif ($model instanceof Enumerable) { 50 | $model->each->makeHidden($field); 51 | } else { 52 | $model->makeHidden($field); 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Http/Controllers/Modifiers/Modifier.php: -------------------------------------------------------------------------------- 1 | merge($this->prepareMedia($this->all())); 21 | } 22 | } 23 | 24 | /** 25 | * @param Validator $validator 26 | * @return void 27 | */ 28 | protected function failedValidation(Validator $validator) 29 | { 30 | if (config('media-library.disk_name') !== 'local') { 31 | $this->clearTempMediaFiles($this->all()); 32 | } 33 | 34 | parent::failedValidation($validator); 35 | } 36 | 37 | /** 38 | * @return void 39 | */ 40 | protected function passedValidation() 41 | { 42 | if (config('media-library.disk_name') !== 'local') { 43 | $this->clearTempMediaFiles($this->all()); 44 | } 45 | 46 | parent::passedValidation(); 47 | } 48 | 49 | /** 50 | * @param array $data 51 | * @return array 52 | */ 53 | private function prepareMedia(array $data): array 54 | { 55 | foreach ($data as $key => $value) { 56 | if (! is_array($value)) { 57 | continue; 58 | } 59 | 60 | if ($key !== '_media' || ! $this->isValidMediaArray($value)) { 61 | $data[$key] = $this->prepareMedia($value); 62 | 63 | continue; 64 | } 65 | 66 | $mediaArray = []; 67 | 68 | foreach ($value as $collectionName => $mediaItems) { 69 | foreach ($mediaItems as $mediaId) { 70 | $media = config('media-library.media_model')::find($mediaId); 71 | 72 | if (! $media || $media->model_type !== MediaTemporaryUpload::class) { 73 | $mediaArray[$collectionName][] = $mediaId; 74 | 75 | continue; 76 | } 77 | 78 | abort_if(Auth::id() !== $media->model->created_by_user_id, 404); 79 | 80 | if (config('media-library.disk_name') === 'local') { 81 | $mediaArray[$collectionName][] = new MediaFile($media->getPath(), $mediaId); 82 | } else { 83 | $tempFilePath = tempnam(storage_path(), 'junction-temp-media-'); 84 | file_put_contents($tempFilePath, stream_get_contents($media->stream())); 85 | 86 | $mediaArray[$collectionName][] = new MediaFile($tempFilePath, $mediaId); 87 | } 88 | } 89 | } 90 | 91 | $data[$key] = $mediaArray; 92 | } 93 | 94 | return $data; 95 | } 96 | 97 | /** 98 | * @param array $data 99 | * @return void 100 | */ 101 | private function clearTempMediaFiles(array $data): void 102 | { 103 | foreach ($data as $key => $value) { 104 | if (! is_array($value)) { 105 | continue; 106 | } 107 | 108 | if ($key !== '_media' || ! $this->isValidMediaFileArray($value)) { 109 | $this->clearTempMediaFiles($value); 110 | 111 | continue; 112 | } 113 | 114 | foreach ($value as $mediaFiles) { 115 | /** @var MediaFile $mediaFile */ 116 | foreach ($mediaFiles as $mediaFile) { 117 | if (file_exists($mediaFile->getRealPath())) { 118 | unlink($mediaFile->getRealPath()); 119 | } 120 | } 121 | } 122 | } 123 | } 124 | 125 | /** 126 | * @param array $array 127 | * @return bool 128 | */ 129 | private function isValidMediaArray(array $array): bool 130 | { 131 | $hasNonEmptyValue = false; 132 | 133 | foreach ($array as $key => $value) { 134 | if (! is_string($key)) { 135 | return false; 136 | } 137 | 138 | if (! empty($value)) { 139 | $hasNonEmptyValue = true; 140 | 141 | if (array_values($value) !== $value || array_filter($value, 'is_int') !== $value) { 142 | return false; 143 | } 144 | } 145 | } 146 | 147 | return $hasNonEmptyValue; 148 | } 149 | 150 | /** 151 | * @param array> $array 152 | * @return bool 153 | */ 154 | private function isValidMediaFileArray(array $array): bool 155 | { 156 | foreach ($array as $value) { 157 | if (empty($value)) { 158 | return false; 159 | } 160 | 161 | foreach ($value as $item) { 162 | if (! ($item instanceof MediaFile)) { 163 | return false; 164 | } 165 | } 166 | } 167 | 168 | return true; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/Http/Controllers/Resources/BaseResource.php: -------------------------------------------------------------------------------- 1 | models()); 42 | 43 | $resourceCollection->resource->each->pluckFields($pluckAttributes, $pluckAccessors, $pluckRelations); 44 | 45 | if ($paginator = $items->paginator()) { 46 | $resourceCollection->additional([ 47 | 'total' => $paginator instanceof LengthAwarePaginator ? $paginator->total() : null, 48 | 'page' => $paginator?->currentPage(), 49 | 'has_next_page' => $paginator?->hasMorePages(), 50 | ]); 51 | } 52 | 53 | return $resourceCollection; 54 | } 55 | 56 | /** 57 | * @param array|null $pluckAttributes 58 | * @param array|null $pluckAccessors 59 | * @param array|null $pluckRelations 60 | * @return $this 61 | */ 62 | public function pluckFields(?array $pluckAttributes = null, ?array $pluckAccessors = null, ?array $pluckRelations = null): static 63 | { 64 | $this->pluckAttributes = $pluckAttributes; 65 | $this->pluckAccessors = $pluckAccessors; 66 | $this->pluckRelations = $pluckRelations; 67 | 68 | return $this; 69 | } 70 | 71 | /** 72 | * Transform the resource into an array. 73 | * 74 | * @param Request $request 75 | * @return array 76 | */ 77 | public function toArray(Request $request): array 78 | { 79 | return array_merge( 80 | $this->relationsToArray($request), 81 | $this->attributesToArray($request), 82 | $this->accessorsToArray($request), 83 | ); 84 | } 85 | 86 | /** 87 | * @param Request $request 88 | * @return array 89 | */ 90 | protected function relationsToArray(Request $request): array 91 | { 92 | $relations = $this->availableRelations(); 93 | 94 | // If no pluck relations are given, return nothing 95 | if ($this->pluckRelations === null) { 96 | return []; 97 | } 98 | 99 | // Only get available relations (if present) 100 | $relations = $relations !== null 101 | ? collect($relations)->filter(fn ($resource, $field) => array_key_exists($field, $this->pluckRelations)) 102 | : collect($this->pluckRelations)->mapWithKeys(fn ($_, $relation) => [$relation => self::class]); 103 | 104 | return $relations->map(function ($resourceClass, $field) use ($request) { 105 | if ($this->resource->$field === null) { 106 | return null; 107 | } 108 | 109 | if ($this->resource->$field instanceof Collection) { 110 | $resourceCollection = $resourceClass::collection($this->resource->$field); 111 | 112 | // Run pluck for each resource in the collection 113 | $resourceCollection->resource->each->pluckFields( 114 | $this->pluckAttributes[$field] ?? null, 115 | $this->pluckAccessors[$field] ?? null, 116 | is_array($this->pluckRelations[$field] ?? null) ? $this->pluckRelations[$field] : null, 117 | ); 118 | 119 | return $resourceCollection->toArray($request); 120 | } 121 | 122 | $resource = new $resourceClass($this->resource->$field); 123 | 124 | $resource->pluckFields( 125 | $this->pluckAttributes[$field] ?? null, 126 | $this->pluckAccessors[$field] ?? null, 127 | is_array($this->pluckRelations[$field] ?? null) ? $this->pluckRelations[$field] : null, 128 | ); 129 | 130 | return $resource->toArray($request); 131 | })->toArray(); 132 | } 133 | 134 | /** 135 | * @param Request $request 136 | * @return array 137 | */ 138 | protected function attributesToArray(Request $request): array 139 | { 140 | $attributes = $this->availableAttributes(); 141 | 142 | $pluckAttributes = $this->pluckAttributes !== null 143 | ? collect($this->pluckAttributes)->filter(fn ($_, $attribute) => ! $this->resource->isRelation($attribute))->toArray() 144 | : null; 145 | 146 | // If no pluck attributes or available attributes are given, return all attributes 147 | if ($pluckAttributes === null && $attributes === null) { 148 | return $this->resource->only(array_keys(Arr::except($this->resource->getAttributes(), $this->resource->getHidden()))); 149 | } 150 | if ($pluckAttributes !== null) { 151 | // Always add the primary key 152 | $pluckAttributes[$this->resource->getKeyName()] = null; 153 | 154 | // Only get available attributes (if present) 155 | $attributes = $attributes !== null 156 | ? collect($attributes)->filter(fn ($field) => array_key_exists($field, $pluckAttributes))->toArray() 157 | : array_keys($pluckAttributes); 158 | } 159 | 160 | return $this->resource->only($attributes); 161 | } 162 | 163 | /** 164 | * @param Request $request 165 | * @return array 166 | */ 167 | protected function accessorsToArray(Request $request): array 168 | { 169 | if (method_exists($this->resource, 'defaultAppends')) { 170 | collect($this->resource::defaultAppends()) 171 | ->each(function ($accessor) { 172 | if (isset($this->pluckAccessors[$accessor])) { 173 | return; 174 | } 175 | 176 | $this->pluckAccessors[$accessor] = null; 177 | }); 178 | } 179 | 180 | $accessors = $this->availableAccessors(); 181 | 182 | $pluckAccessors = $this->pluckAccessors !== null 183 | ? collect($this->pluckAccessors)->filter(fn ($_, $accessor) => ! $this->resource->isRelation($accessor))->toArray() 184 | : null; 185 | 186 | // If no pluck accessors are given, return nothing 187 | if ($pluckAccessors === null) { 188 | return []; 189 | } 190 | 191 | // Only get available accessors (if present) 192 | $accessors = $accessors !== null 193 | ? collect($accessors)->filter(fn ($field) => array_key_exists($field, $pluckAccessors)) 194 | : collect(array_keys($pluckAccessors)); 195 | 196 | return $this->resource->only($accessors->toArray()); 197 | } 198 | 199 | /** 200 | * @return array|null 201 | */ 202 | protected function availableAttributes(): ?array 203 | { 204 | return null; 205 | } 206 | 207 | /** 208 | * @return array|null 209 | */ 210 | protected function availableAccessors(): ?array 211 | { 212 | return null; 213 | } 214 | 215 | /** 216 | * @return array|null 217 | */ 218 | protected function availableRelations(): ?array 219 | { 220 | return null; 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/Http/Controllers/Response/Item.php: -------------------------------------------------------------------------------- 1 | model = $model; 24 | 25 | return $item; 26 | } 27 | 28 | /** 29 | * @param Closure $param 30 | * @return $this 31 | */ 32 | public function modify(Closure $param): self 33 | { 34 | $param($this->model); 35 | 36 | return $this; 37 | } 38 | 39 | /** 40 | * @return Model 41 | */ 42 | public function getModel(): Model 43 | { 44 | return $this->model; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Http/Controllers/Response/Items.php: -------------------------------------------------------------------------------- 1 | query = $query; 51 | 52 | return $items; 53 | } 54 | 55 | /** 56 | * @param bool $simplePagination 57 | * @return $this 58 | */ 59 | public function simplePagination(bool $simplePagination): Items 60 | { 61 | $this->simplePagination = $simplePagination; 62 | 63 | return $this; 64 | } 65 | 66 | /** 67 | * @param bool $enforceOrderByModelKey 68 | * @param string|null $direction 69 | * @return $this 70 | */ 71 | public function enforceOrderByModelKey(bool $enforceOrderByModelKey, ?string $direction = 'asc'): Items 72 | { 73 | $this->enforceOrderByModelKey = $enforceOrderByModelKey; 74 | $this->enforceOrderByModelKeyDirection = $direction; 75 | 76 | return $this; 77 | } 78 | 79 | /** 80 | * @return $this 81 | */ 82 | public function get(): self 83 | { 84 | $columns = [$this->query->getModel()->getTable() . '.*']; 85 | $perPage = request()?->input('paginate'); 86 | 87 | $this->handleEnforceOrderByModelKey(); 88 | 89 | if ($perPage) { 90 | $page = $this->page($perPage); 91 | 92 | if ($this->simplePagination) { 93 | $this->paginator = $this->query->simplePaginate($perPage, $columns, 'page', $page); 94 | } else { 95 | $this->paginator = $this->query->paginate($perPage, $columns, 'page', $page); 96 | } 97 | 98 | $this->models = collect($this->paginator->items()); 99 | 100 | return $this; 101 | } 102 | 103 | $this->models = $this->query->get($columns); 104 | 105 | return $this; 106 | } 107 | 108 | /** 109 | * @param Closure $param 110 | * @return $this 111 | */ 112 | public function modify(Closure $param): self 113 | { 114 | $this->models->each($param); 115 | 116 | return $this; 117 | } 118 | 119 | /** 120 | * @return Enumerable 121 | */ 122 | public function models(): Enumerable 123 | { 124 | return $this->models; 125 | } 126 | 127 | /** 128 | * @return Paginator|null 129 | */ 130 | public function paginator(): ?Paginator 131 | { 132 | return $this->paginator; 133 | } 134 | 135 | /** 136 | * @param $perPage 137 | * @return int|null 138 | */ 139 | protected function page($perPage): ?int 140 | { 141 | $page = request()?->input('page') ?: 1; 142 | 143 | $idToFind = request()?->input('page_for_id'); 144 | 145 | if (! $idToFind) { 146 | return $page; 147 | } 148 | 149 | if ($this->query->getModel()->getKeyType() === 'int') { 150 | $idToFind = (int) $idToFind; 151 | } 152 | 153 | $keyName = $this->query->getModel()->getKeyName(); 154 | 155 | $index = $this->query->toBase() 156 | ->clone() 157 | ->select([$this->query->getModel()->getTable() . '.' . $keyName]) 158 | ->cursor() 159 | ->search(function ($data) use ($idToFind, $keyName) { 160 | return $data->$keyName == $idToFind; 161 | }); 162 | 163 | if ($index === false) { 164 | return $page; 165 | } 166 | 167 | return ceil(($index + 1) / $perPage); 168 | } 169 | 170 | /** 171 | * @return void 172 | */ 173 | protected function handleEnforceOrderByModelKey(): void 174 | { 175 | if (! $this->enforceOrderByModelKey) { 176 | return; 177 | } 178 | 179 | $baseQuery = $this->query->getQuery(); 180 | $combinedQueryOrders = [ 181 | ...($baseQuery->orders ?? []), 182 | ...($baseQuery->unionOrders ?? []), 183 | ]; 184 | 185 | $model = $this->query->getModel(); 186 | $modelKeyName = $model->getKeyName(); 187 | $modelQualifiedKeyName = $model->getQualifiedKeyName(); 188 | 189 | $hasOrderByModelKey = collect($combinedQueryOrders)->whereIn('column', [$modelKeyName, $modelQualifiedKeyName])->isNotEmpty(); 190 | 191 | if (! $hasOrderByModelKey) { 192 | $this->query->orderBy($modelQualifiedKeyName, $this->enforceOrderByModelKeyDirection ?? 'asc'); 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/Http/Controllers/Response/Response.php: -------------------------------------------------------------------------------- 1 | validate([ 17 | 'action' => [ 18 | 'required', 19 | Rule::in($this->getActions()), 20 | ], 21 | ]); 22 | 23 | $model = null; 24 | 25 | if (request()->id) { 26 | $model = $this->model::find(request()->id); 27 | 28 | if (! $model) { 29 | abort(404, 'Record not found.'); 30 | } 31 | } 32 | 33 | if ($this->usePolicy && ! Auth::user()->can('action', $model ?: $this->model)) { 34 | abort(403, 'Unauthorized'); 35 | } 36 | 37 | return $this->{$this->getActionMethod(request()->action)}($model); 38 | } 39 | 40 | /** 41 | * @param $name 42 | * @return \Illuminate\Support\Stringable 43 | */ 44 | protected function getActionMethod($name) 45 | { 46 | $exists = (bool) $this->getActions()->first(function ($action) use ($name) { 47 | return $action == $name; 48 | }); 49 | 50 | return $exists 51 | ? (string) Str::of($name)->studly()->prepend('action') 52 | : null; 53 | } 54 | 55 | /** 56 | * @return \Illuminate\Support\Collection 57 | */ 58 | protected function getActions() 59 | { 60 | return $this->getActionMethods()->map(function ($method) { 61 | return (string) Str::of($method)->remove('action')->camel(); 62 | }); 63 | } 64 | 65 | /** 66 | * @return \Illuminate\Support\Collection 67 | */ 68 | protected function getActionMethods() 69 | { 70 | return collect(get_class_methods($this))->filter(function ($method) { 71 | return Str::of($method)->startsWith('action') 72 | && $method !== 'action'; 73 | })->values(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Http/Controllers/Traits/HasDefaultAppends.php: -------------------------------------------------------------------------------- 1 | appends; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Http/Controllers/Traits/HasDestroy.php: -------------------------------------------------------------------------------- 1 | {$id->getKeyName()}; 21 | } 22 | 23 | $model = $this->model::find($id); 24 | 25 | if (! $model) { 26 | abort(404, 'Record not found.'); 27 | } 28 | 29 | if ($this->usePolicy && ! Auth::user()->can('delete', $model)) { 30 | abort(403, 'Unauthorized'); 31 | } 32 | 33 | $this->beforeDestroy($model); 34 | 35 | $model->delete(); 36 | 37 | return response()->json( 38 | $this->afterDestroy($model) 39 | ); 40 | } 41 | 42 | /** 43 | * @param Model $model 44 | */ 45 | public function beforeDestroy(Model $model) 46 | { 47 | // 48 | } 49 | 50 | /** 51 | * @param Model $model 52 | * @return Model 53 | */ 54 | public function afterDestroy(Model $model) 55 | { 56 | return $model; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Http/Controllers/Traits/HasIndex.php: -------------------------------------------------------------------------------- 1 | usePolicy && ! Auth::user()->can('viewAny', $this->model)) { 33 | abort(403, 'Unauthorized'); 34 | } 35 | 36 | $simplePagination = request()->boolean('simple_pagination'); 37 | 38 | if ($this->forceSimplePagination === true && ! $simplePagination) { 39 | abort(400, 'Simple pagination is required for this resource.'); 40 | } 41 | 42 | /** @var Builder $query */ 43 | $query = $this->model::query(); 44 | 45 | $this->beforeIndex($query); 46 | 47 | Relations::apply($this, $query); 48 | Scopes::apply($this, $query); 49 | Search::apply($this, $query); 50 | Wheres::apply($this, $query); 51 | WhereIn::apply($this, $query); 52 | WhereNotIn::apply($this, $query); 53 | Limit::apply($this, $query); 54 | Order::apply($this, $query); 55 | Count::apply($this, $query); 56 | 57 | $items = Items::query($query) 58 | ->simplePagination($simplePagination) 59 | ->enforceOrderByModelKey( 60 | (bool) config('junction.route.index.enforce_order_by_model_key', false), 61 | config('junction.route.index.enforce_order_by_model_key_direction'), 62 | ) 63 | ->get(); 64 | 65 | HiddenFields::apply($this, $items); 66 | Appends::apply($this, $items); 67 | 68 | $this->afterIndex($items); 69 | 70 | $pluckFields = request()?->getPluckFields(); 71 | $accessors = request()?->getAccessors(); 72 | $relations = request()?->getRelations(); 73 | 74 | return $this->resource::items( 75 | $items, 76 | pluckAttributes: $pluckFields !== null ? Arr::undot(array_flip($pluckFields)) : null, 77 | pluckAccessors: $accessors !== null ? Arr::undot(array_flip($accessors)) : null, 78 | pluckRelations: $relations !== null ? Arr::undot(array_flip($relations)) : null, 79 | ); 80 | } 81 | 82 | /** 83 | * @param Builder $query 84 | */ 85 | public function beforeIndex(Builder &$query) 86 | { 87 | // 88 | } 89 | 90 | /** 91 | * @param Items $items 92 | */ 93 | public function afterIndex(Items &$items) 94 | { 95 | // 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Http/Controllers/Traits/HasMedia.php: -------------------------------------------------------------------------------- 1 | $validAttributes 16 | * @return array 17 | */ 18 | public function attachMedia(Model $model, array $validAttributes): array 19 | { 20 | if (! class_exists(Media::class) || ! config('media-library.media_model')) { 21 | return []; 22 | } 23 | 24 | $mediaFiles = []; 25 | 26 | foreach ($validAttributes as $key => $value) { 27 | if (! is_array($value)) { 28 | continue; 29 | } 30 | 31 | if ($key !== '_media') { 32 | if ($model->$key instanceof Model) { 33 | $this->attachMedia($model->$key, $value); 34 | } 35 | 36 | continue; 37 | } 38 | 39 | if (! $this->isValidMediaArray($value)) { 40 | continue; 41 | } 42 | 43 | foreach ($value as $collectionName => $uploadedFiles) { 44 | /** @var MediaFile $uploadedFile */ 45 | foreach ($uploadedFiles as $uploadedFile) { 46 | /** @var Media $media */ 47 | $media = config('media-library.media_model')::findOrFail($uploadedFile->mediaId); 48 | 49 | abort_if($media->model_type !== MediaTemporaryUpload::class || Auth::id() !== $media->model->created_by_user_id, 404); 50 | 51 | $media = $this->beforeMediaUpload($media, $model, $collectionName); 52 | 53 | $oldMediaTemporaryUpload = $media->model; 54 | $media = $media->move($model, $collectionName); 55 | 56 | $mediaFiles[] = $media; 57 | 58 | if ($oldMediaTemporaryUpload->media->isEmpty()) { 59 | $oldMediaTemporaryUpload->delete(); 60 | } 61 | 62 | $this->afterMediaUpload($media, $model); 63 | } 64 | } 65 | } 66 | 67 | return $mediaFiles; 68 | } 69 | 70 | /** 71 | * @param array $array 72 | * @return bool 73 | */ 74 | private function isValidMediaArray(array $array): bool 75 | { 76 | foreach ($array as $key => $value) { 77 | if (! is_string($key) || array_values($value) !== $value || array_filter($value, static fn ($item) => $item instanceof MediaFile) !== $value) { 78 | return false; 79 | } 80 | } 81 | 82 | return true; 83 | } 84 | 85 | /** 86 | * @param Media $media 87 | * @param Model $model 88 | * @param string $collectionName 89 | * @return Media 90 | */ 91 | public function beforeMediaUpload(Media $media, Model $model, string $collectionName): Media 92 | { 93 | return $media; 94 | } 95 | 96 | /** 97 | * @param Media $media 98 | * @param Model $model 99 | * @return void 100 | */ 101 | public function afterMediaUpload(Media $media, Model $model): void 102 | { 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Http/Controllers/Traits/HasShow.php: -------------------------------------------------------------------------------- 1 | {$id->getKeyName()}; 33 | } 34 | 35 | $query = $this->model::query(); 36 | 37 | $this->beforeShow($query); 38 | 39 | Relations::apply($this, $query); 40 | Scopes::apply($this, $query); 41 | Wheres::apply($this, $query); 42 | WhereIn::apply($this, $query); 43 | WhereNotIn::apply($this, $query); 44 | Count::apply($this, $query); 45 | 46 | $model = $query->find($id); 47 | 48 | if (! $model) { 49 | abort(404, 'Record not found.'); 50 | } 51 | 52 | if ($this->usePolicy && ! Auth::user()->can('view', $model)) { 53 | abort(403, 'Unauthorized'); 54 | } 55 | 56 | $item = Item::model($model); 57 | 58 | HiddenFields::apply($this, $item); 59 | Appends::apply($this, $item); 60 | 61 | $this->afterShow($item); 62 | 63 | $pluckFields = request()?->getPluckFields(); 64 | $accessors = request()?->getAccessors(); 65 | $relations = request()?->getRelations(); 66 | 67 | $this->resource::withoutWrapping(); 68 | 69 | return (new $this->resource($item->getModel()))->pluckFields( 70 | pluckAttributes: $pluckFields !== null ? Arr::undot(array_flip($pluckFields)) : null, 71 | pluckAccessors: $accessors !== null ? Arr::undot(array_flip($accessors)) : null, 72 | pluckRelations: $relations !== null ? Arr::undot(array_flip($relations)) : null, 73 | ); 74 | } 75 | 76 | /** 77 | * @param Builder $query 78 | */ 79 | public function beforeShow(Builder &$query) 80 | { 81 | // 82 | } 83 | 84 | /** 85 | * @param Item $item 86 | */ 87 | public function afterShow(Item &$item) 88 | { 89 | // 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Http/Controllers/Traits/HasStore.php: -------------------------------------------------------------------------------- 1 | usePolicy && ! Auth::user()->can('create', $this->model)) { 18 | abort(403, 'Unauthorized'); 19 | } 20 | 21 | if (! is_a($this->formRequest, FormRequest::class, true)) { 22 | throw new Exception('Property `formRequest` should inherit from `FormRequest::class`.'); 23 | } 24 | 25 | $request = app($this->formRequest); 26 | $model = new $this->model(); 27 | 28 | $validAttributes = $this->saveFillable ? $request->only($model->getFillable()) : $request->validated(); 29 | $invalidAttributes = array_diff_key($request->all(), $validAttributes); 30 | 31 | $model->fill( 32 | $this->beforeStore($validAttributes, $invalidAttributes) 33 | ); 34 | 35 | $model->save(); 36 | $this->attachMedia($model, $validAttributes); 37 | 38 | return response()->json( 39 | $this->afterStore($model, $validAttributes, $invalidAttributes) 40 | ); 41 | } 42 | 43 | /** 44 | * @param array $validAttributes 45 | * @param array $invalidAttributes 46 | * @return array 47 | */ 48 | public function beforeStore(array $validAttributes, array $invalidAttributes) 49 | { 50 | return $validAttributes; 51 | } 52 | 53 | /** 54 | * @param Model $model 55 | * @param array $validAttributes 56 | * @param array $invalidAttributes 57 | * @return Model 58 | */ 59 | public function afterStore(Model $model, array $validAttributes, array $invalidAttributes) 60 | { 61 | return $model; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Http/Controllers/Traits/HasUpdate.php: -------------------------------------------------------------------------------- 1 | {$id->getKeyName()}; 20 | } 21 | 22 | $model = $this->model::find($id); 23 | 24 | if (! $model) { 25 | abort(404, 'Record not found.'); 26 | } 27 | 28 | if ($this->usePolicy && ! Auth::user()->can('update', $model)) { 29 | abort(403, 'Unauthorized'); 30 | } 31 | 32 | if (! is_a($this->formRequest, FormRequest::class, true)) { 33 | throw new Exception('Property `formRequest` should inherit from `FormRequest::class`.'); 34 | } 35 | 36 | $request = app($this->formRequest); 37 | 38 | $validAttributes = $this->saveFillable ? $request->only($model->getFillable()) : $request->validated(); 39 | $invalidAttributes = array_diff_key($request->all(), $validAttributes); 40 | 41 | $model->fill( 42 | $this->beforeUpdate($model, $validAttributes, $invalidAttributes) 43 | ); 44 | 45 | $model->save(); 46 | 47 | $this->attachMedia($model, $validAttributes); 48 | 49 | return response()->json( 50 | $this->afterUpdate($model, $validAttributes, $invalidAttributes) 51 | ); 52 | } 53 | 54 | /** 55 | * @param Model $model 56 | * @param array $validAttributes 57 | * @param array $invalidAttributes 58 | * @return array 59 | */ 60 | public function beforeUpdate(Model $model, array $validAttributes, array $invalidAttributes) 61 | { 62 | return $validAttributes; 63 | } 64 | 65 | /** 66 | * @param Model $model 67 | * @param array $validAttributes 68 | * @param array $invalidAttributes 69 | * @return Model 70 | */ 71 | public function afterUpdate(Model $model, array $validAttributes, array $invalidAttributes) 72 | { 73 | return $model; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Http/Controllers/Validators/Appends.php: -------------------------------------------------------------------------------- 1 | isEmpty()) { 23 | return []; 24 | } 25 | 26 | $model = new $controller->model(); 27 | 28 | $check = $appends->count() == $appends->filter(function ($append) use ($model) { 29 | if (Str::contains($append, '.')) { 30 | // TODO Validate relation appends 31 | 32 | return true; 33 | } 34 | 35 | return $model->hasGetMutator($append) || $model->hasAttributeGetMutator($append); 36 | })->count(); 37 | 38 | if ($check) { 39 | return $appends->toArray(); 40 | } 41 | 42 | throw ValidationException::withMessages([ 43 | 'appends' => 'Invalid appends', 44 | ]); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Http/Controllers/Validators/Relations.php: -------------------------------------------------------------------------------- 1 | function($query){ return $query; }]` 13 | * 14 | * @param Controller $controller 15 | * @param array $relations 16 | * @return array 17 | * 18 | * @throws ValidationException 19 | */ 20 | public static function validate(Controller $controller, array $relations) 21 | { 22 | $relations = collect($relations); 23 | 24 | if ($relations->isEmpty()) { 25 | return []; 26 | } 27 | 28 | $availableRelations = $controller->relations(); 29 | 30 | if ($availableRelations == ['*']) { 31 | return $relations->all(); 32 | } 33 | 34 | $invalidRelations = $relations->filter(function ($callback, $relation) use ($availableRelations) { 35 | $key = is_string($callback) ? $callback : $relation; 36 | 37 | return ! array_key_exists($key, $availableRelations) && ! in_array($key, $availableRelations); 38 | }); 39 | 40 | throw_if($invalidRelations->isNotEmpty(), ValidationException::withMessages([ 41 | 'relations' => "Invalid relation(s): {$invalidRelations->join(', ')}", 42 | ])); 43 | 44 | return $relations->all(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Http/Controllers/Validators/Scopes.php: -------------------------------------------------------------------------------- 1 | isEmpty()) { 22 | return []; 23 | } 24 | 25 | $filteredScopes = $scopeCollection->filter(function ($scope) use ($controller) { 26 | return app($controller->model)::query()->hasNamedScope($scope['name']); 27 | }); 28 | 29 | if ($filteredScopes->count() !== $scopeCollection->count()) { 30 | throw ValidationException::withMessages([ 31 | 'scopes' => 'Invalid scopes', 32 | ]); 33 | } 34 | 35 | return $scopeCollection->toArray(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Http/Utilities/MediaFile.php: -------------------------------------------------------------------------------- 1 | mediaId = $mediaId; 21 | 22 | parent::__construct($path); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Junction.php: -------------------------------------------------------------------------------- 1 | >> 13 | */ 14 | public static array $cachedAttributeRelations = []; 15 | 16 | /** 17 | * @param $uri 18 | * @param $controller 19 | * @param mixed $only 20 | * @return void 21 | * 22 | * @deprecated Replaced by Route::junctionResource(). 23 | */ 24 | public static function resource($uri, $controller, $only = ['index', 'indexPost', 'store', 'show', 'showPost', 'update', 'destroy', 'action']): void 25 | { 26 | Route::junctionResource($uri, $controller)->only($only); 27 | } 28 | 29 | /** 30 | * @param callable|null $get 31 | * @param callable|null $set 32 | * @param array $with 33 | * @return Attribute 34 | */ 35 | public static function makeAttribute(?callable $get = null, ?callable $set = null, array $with = []): Attribute 36 | { 37 | $attribute = Attribute::make($get, $set); 38 | 39 | if ($caller = debug_backtrace()[1] ?? null) { 40 | static::$cachedAttributeRelations[$caller['class']][$caller['function']] ??= $with; 41 | } 42 | 43 | return $attribute; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/JunctionServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 22 | __DIR__ . '/../database/migrations' => database_path('migrations'), 23 | ], 'migrations'); 24 | 25 | $this->publishes([ 26 | __DIR__ . '/../config/junction.php' => config_path('junction.php'), 27 | ]); 28 | 29 | $this->commands([ 30 | CleanMediaTemporaryUploads::class, 31 | ]); 32 | 33 | if (class_exists(Media::class) && config('junction.route.media.enabled', true)) { 34 | Route::middleware(config('junction.route.media.middleware', ['api'])) 35 | ->prefix(config('junction.route.media.prefix', '')) 36 | ->group(__DIR__ . '/../routes/media_library.php'); 37 | } 38 | 39 | $this->bootRouteMacros(); 40 | 41 | $this->bootRequestMacros(); 42 | } 43 | 44 | /** 45 | * @return void 46 | */ 47 | protected function bootRouteMacros(): void 48 | { 49 | Route::macro('junctionResource', function ($name, $controller, array $options = []) { 50 | $defaults = ['index', 'indexPost', 'store', 'show', 'showPost', 'update', 'destroy', 'action']; 51 | 52 | $only = $options['only'] ?? $defaults; 53 | 54 | if (isset($options['except'])) { 55 | $only = array_diff($only, (array) $options['except']); 56 | } 57 | 58 | $registrar = new ResourceRegistrar($this); 59 | 60 | return new PendingResourceRegistration( 61 | $registrar, 62 | $name, 63 | $controller, 64 | array_merge(['only' => $only], $options) 65 | ); 66 | }); 67 | } 68 | 69 | /** 70 | * @return void 71 | */ 72 | protected function bootRequestMacros(): void 73 | { 74 | Request::macro('getPluckFields', fn () => $this->input('pluck')); 75 | 76 | Request::macro('getAccessors', fn () => $this->input('appends')); 77 | 78 | Request::macro('getRelations', function () { 79 | $relations = $this->input('with'); 80 | 81 | foreach ($this->getAccessors() ?? [] as $accessor) { 82 | if (! Str::contains($accessor, '.')) { 83 | continue; 84 | } 85 | 86 | $accessorRelation = Str::beforeLast($accessor, '.'); 87 | 88 | if (! Arr::first($relations ?? [], fn ($relation) => Str::startsWith($relation, $accessorRelation))) { 89 | $relations ??= []; 90 | $relations[] = $accessorRelation; 91 | } 92 | } 93 | 94 | return $relations; 95 | }); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Models/MediaTemporaryUpload.php: -------------------------------------------------------------------------------- 1 | belongsTo(config('auth.providers.users.model'), 'created_by_user_id'); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/ResourceRegistrar.php: -------------------------------------------------------------------------------- 1 | getResourceUri($name) . '/index'; 32 | 33 | unset($options['missing']); 34 | 35 | $action = $this->getResourceAction($name, $controller, 'indexPost', $options); 36 | $action['uses'] = Str::replaceLast('Post', '', $action['uses']); 37 | 38 | return $this->router->post($uri, $action); 39 | } 40 | 41 | /** 42 | * Add the show method for a resourceful route. 43 | * 44 | * @param string $name 45 | * @param string $base 46 | * @param string $controller 47 | * @param array $options 48 | * @return Route 49 | */ 50 | protected function addResourceShowPost(string $name, string $base, string $controller, array $options): Route 51 | { 52 | $name = $this->getShallowName($name, $options); 53 | 54 | $uri = $this->getResourceUri($name) . '/{' . $base . '}/show'; 55 | 56 | $action = $this->getResourceAction($name, $controller, 'showPost', $options); 57 | $action['uses'] = Str::replaceLast('Post', '', $action['uses']); 58 | 59 | return $this->router->post($uri, $action); 60 | } 61 | 62 | /** 63 | * Add the action method for a resourceful route. 64 | * 65 | * @param string $name 66 | * @param string $base 67 | * @param string $controller 68 | * @param array $options 69 | * @return Route 70 | */ 71 | public function addResourceAction(string $name, string $base, string $controller, array $options): Route 72 | { 73 | $name = $this->getShallowName($name, $options); 74 | 75 | $uri = $this->getResourceUri($name); 76 | 77 | $action = $this->getResourceAction($name, $controller, 'action', $options); 78 | 79 | return $this->router->put($uri, $action); 80 | } 81 | } 82 | --------------------------------------------------------------------------------