├── .gitignore ├── phpunit.xml ├── src ├── UrlExtension.php ├── Extension.php ├── MimeExtension.php └── Response.php ├── composer.json ├── license.txt ├── tests └── MultFormatResponseTest.php └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | .phpunit.result.cache 3 | composer.lock 4 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | ./tests/ 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/UrlExtension.php: -------------------------------------------------------------------------------- 1 | extension($this->filename($request)); 14 | } 15 | 16 | private function extension(string $filename): ?string 17 | { 18 | if (! Str::contains($filename, '.')) { 19 | return null; 20 | } 21 | 22 | return Arr::last(explode('.', $filename)); 23 | } 24 | 25 | private function filename(Request $request): string 26 | { 27 | return Arr::last(explode('/', $request->path())); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Extension.php: -------------------------------------------------------------------------------- 1 | formatOverrides = $formatOverrides; 17 | } 18 | 19 | public function parse(Request $request): ?string 20 | { 21 | return $this->urlExtension($request) ?? $this->acceptHeaderExtension($request); 22 | } 23 | 24 | private function acceptHeaderExtension(Request $request) : ?string 25 | { 26 | return (new MimeExtension($this->formatOverrides))->parse($request); 27 | } 28 | 29 | private function urlExtension(Request $request): ?string 30 | { 31 | return (new UrlExtension)->parse($request); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timacdonald/multiformat-response-objects", 3 | "description": "A response object that handles multiple response formats within the one controller", 4 | "license": "MIT", 5 | "keywords": [ 6 | "multiformat", 7 | "responsable", 8 | "response objects", 9 | "laravel" 10 | ], 11 | "authors": [ 12 | { 13 | "name": "Tim MacDonald", 14 | "email": "hello@timacdonald.me", 15 | "homepage": "https://timacdonald.me" 16 | } 17 | ], 18 | "require": { 19 | "php": "^7.2", 20 | "illuminate/support": "5.8.*", 21 | "illuminate/http": "5.8.*", 22 | "symfony/mime": "^4.3" 23 | }, 24 | "require-dev": { 25 | "phpunit/phpunit": "^8.0", 26 | "orchestra/testbench": "^3.5" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "TiMacDonald\\MultiFormat\\": "src" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Tests\\": "tests/" 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Tim MacDonald 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/MimeExtension.php: -------------------------------------------------------------------------------- 1 | overrides = $overrides; 23 | 24 | $this->mimeTypes = new MimeTypes; 25 | } 26 | 27 | public function parse(Request $request): ?string 28 | { 29 | foreach ($request->getAcceptableContentTypes() as $contentType) { 30 | $extension = $this->getOverride($contentType) ?? $this->getExtension($contentType); 31 | 32 | if ($extension !== null) { 33 | return $extension; 34 | } 35 | } 36 | 37 | return $request->format(null); 38 | } 39 | 40 | private function getExtension(string $contentType): ?string 41 | { 42 | return $this->mimeTypes->getExtensions($contentType)[0] ?? null; 43 | } 44 | 45 | private function getOverride(string $contentType): ?string 46 | { 47 | return $this->overrides[$contentType] ?? null; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Response.php: -------------------------------------------------------------------------------- 1 | with($data); 31 | } 32 | 33 | public function with($data): self 34 | { 35 | $this->data = array_merge($this->data, $data); 36 | 37 | return $this; 38 | } 39 | 40 | public function withDefaultFormat(string $format): self 41 | { 42 | $this->defaultFormat = $format; 43 | 44 | return $this; 45 | } 46 | 47 | public function withFormatOverrides(array $formatOverrides): self 48 | { 49 | $this->formatOverrides = array_merge($this->formatOverrides, $formatOverrides); 50 | 51 | return $this; 52 | } 53 | 54 | /** 55 | * @param \Illuminate\Http\Request $request 56 | * @return \Symfony\Component\HttpFoundation\Response 57 | */ 58 | public function toResponse($request) 59 | { 60 | return Container::getInstance()->call([$this, $this->responseMethod($request)], [ 61 | 'request' => $request, 62 | ]); 63 | } 64 | 65 | private function responseMethod(Request $request): string 66 | { 67 | return 'to'.Str::studly($this->extension($request)).'Response'; 68 | } 69 | 70 | private function extension(Request $request): string 71 | { 72 | return (new Extension($this->formatOverrides))->parse($request) ?? $this->defaultFormat; 73 | } 74 | 75 | /** 76 | * @return mixed 77 | */ 78 | public function __get(string $key) 79 | { 80 | if (array_key_exists($key, $this->data)) { 81 | return $this->data[$key]; 82 | } 83 | 84 | throw new Exception('Accessing undefined attribute '.static::class.'::'.$key); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/MultFormatResponseTest.php: -------------------------------------------------------------------------------- 1 | withoutExceptionHandling(); 18 | } 19 | 20 | public function test_can_instantiate_instance_with_make_and_data_is_available() 21 | { 22 | $instance = TestResponse::make(['property' => 'expected']); 23 | 24 | $this->assertSame('expected', $instance->property); 25 | } 26 | 27 | public function test_can_add_data_using_with_and_retrieve_with_magic_get() 28 | { 29 | $instance = (new TestResponse)->with(['property' => 'expected value']); 30 | 31 | $this->assertSame('expected value', $instance->property); 32 | } 33 | 34 | public function test_access_to_non_existent_attribute_throws_exception() 35 | { 36 | $this->expectException(Exception::class); 37 | $this->expectExceptionMessage('Accessing undefined attribute Tests\TestResponse::not_set'); 38 | 39 | (new TestResponse)->not_set; 40 | } 41 | 42 | public function test_with_merges_data() 43 | { 44 | $instance = new TestResponse; 45 | $instance->with(['property_1' => 'expected value 1']); 46 | $instance->with(['property_2' => 'expected value 2']); 47 | 48 | $this->assertSame('expected value 1', $instance->property_1); 49 | $this->assertSame('expected value 2', $instance->property_2); 50 | } 51 | 52 | public function test_with_overrides_when_passing_duplicate_key() 53 | { 54 | $instance = new TestResponse; 55 | $instance->with(['property' => 1]); 56 | $instance->with(['property' => 2]); 57 | 58 | $this->assertSame(2, $instance->property); 59 | } 60 | 61 | public function test_is_defaults_to_html_format() 62 | { 63 | Route::get('location', function () { 64 | return new TestResponse; 65 | }); 66 | 67 | $response = $this->get('location'); 68 | 69 | $response->assertOk(); 70 | $this->assertSame('expected html response', $response->content()); 71 | } 72 | 73 | public function test_responds_to_extension_in_the_route() 74 | { 75 | Route::get('location.csv', function () { 76 | return new TestResponse; 77 | }); 78 | 79 | $response = $this->get('location.csv'); 80 | 81 | $response->assertOk(); 82 | $this->assertSame('expected csv response', $response->content()); 83 | } 84 | 85 | public function test_responds_to_accept_header() 86 | { 87 | Route::get('location', function () { 88 | return new TestResponse; 89 | }); 90 | 91 | $response = $this->get('location', [ 92 | 'Accept' => 'application/json', 93 | ]); 94 | 95 | $response->assertOk(); 96 | $this->assertSame('expected json response', $response->content()); 97 | } 98 | 99 | public function test_responds_to_first_matching_accepts_header() 100 | { 101 | Route::get('location', function () { 102 | return new TestResponse; 103 | }); 104 | 105 | $response = $this->get('location', [ 106 | 'Accept' => 'text/csv, text/css', 107 | ]); 108 | 109 | $response->assertOk(); 110 | $this->assertSame('expected csv response', $response->content()); 111 | } 112 | 113 | public function test_responds_to_a_more_obscure_accept_header() 114 | { 115 | Route::get('location', function () { 116 | return new TestResponse; 117 | }); 118 | 119 | $response = $this->get('location', [ 120 | 'Accept' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 121 | ]); 122 | 123 | $response->assertOk(); 124 | $this->assertSame('expected xlsx response', $response->content()); 125 | } 126 | 127 | public function test_last_dot_segement_is_used_as_the_extension_type() 128 | { 129 | Route::get('websites/{domain}{format}', function () { 130 | return new TestResponse; 131 | })->where('format', '.json'); 132 | 133 | $response = $this->get('websites/timacdonald.me.json'); 134 | 135 | $response->assertOk(); 136 | $this->assertSame('expected json response', $response->content()); 137 | } 138 | 139 | public function test_file_extension_takes_precendence_over_accept_header() 140 | { 141 | Route::get('location{format}', function () { 142 | return new TestResponse; 143 | }); 144 | 145 | $response = $this->get('location.csv', [ 146 | 'Accept' => 'application/json', 147 | ]); 148 | 149 | $response->assertOk(); 150 | $this->assertSame('expected csv response', $response->content()); 151 | } 152 | 153 | public function test_root_domain_returns_html_by_default() 154 | { 155 | $this->app->config->set('app.url', 'http://timacdonald.me'); 156 | Route::get('', function () { 157 | return new TestResponse; 158 | }); 159 | 160 | $response = $this->get(''); 161 | 162 | $response->assertOk(); 163 | $this->assertSame('expected html response', $response->content()); 164 | } 165 | 166 | public function test_root_domain_response_to_other_formats() 167 | { 168 | $this->app->config->set('app.url', 'http://timacdonald.me'); 169 | Route::get('.csv', function () { 170 | return new TestResponse; 171 | }); 172 | 173 | $response = $this->get('.csv'); 174 | 175 | $response->assertOk(); 176 | $this->assertSame('expected csv response', $response->content()); 177 | } 178 | 179 | public function test_query_string_has_no_impact() 180 | { 181 | Route::get('location', function () { 182 | return new TestResponse; 183 | }); 184 | 185 | $response = $this->get('location?format=.csv'); 186 | 187 | $response->assertOk(); 188 | $this->assertSame('expected html response', $response->content()); 189 | } 190 | 191 | public function test_container_passes_request_into_format_methods() 192 | { 193 | Route::get('location.csv', function () { 194 | return new class extends Response { 195 | public function toCsvResponse($request) { 196 | return $request->query('parameter'); 197 | } 198 | }; 199 | }); 200 | 201 | $response = $this->get('location.csv?parameter=expected%20value'); 202 | 203 | $response->assertOk(); 204 | $this->assertSame('expected value', $response->content()); 205 | } 206 | 207 | public function test_container_resolves_dependencies_in_format_methods() 208 | { 209 | $this->app->bind(stdClass::class, function () { 210 | $instance = new stdClass; 211 | $instance->property = 'expected value'; 212 | return $instance; 213 | }); 214 | Route::get('location.csv', function () { 215 | return new class extends Response { 216 | public function toCsvResponse(stdClass $stdClass) { 217 | return $stdClass->property; 218 | } 219 | }; 220 | }); 221 | 222 | $response = $this->get('location.csv'); 223 | 224 | $response->assertOk(); 225 | $this->assertSame('expected value', $response->content()); 226 | } 227 | 228 | public function test_can_set_default_response_format() 229 | { 230 | Route::get('location', function () { 231 | return TestResponse::make()->withDefaultFormat('csv'); 232 | }); 233 | 234 | $response = $this->get('location', ['Accept' => null]); 235 | 236 | $response->assertOk(); 237 | $this->assertSame('expected csv response', $response->content()); 238 | } 239 | 240 | public function test_exception_is_throw_if_no_response_method_exists() 241 | { 242 | $this->expectExceptionMessage('Method Tests\TestResponse::toMp3Response() does not exist'); 243 | 244 | Route::get('location{format}', function () { 245 | return new TestResponse; 246 | }); 247 | 248 | $response = $this->get('location.mp3'); 249 | } 250 | 251 | public function test_url_html_format_is_used_when_the_default_has_another_value() 252 | { 253 | Route::get('location{format}', function () { 254 | return (new TestResponse)->withDefaultFormat('csv'); 255 | }); 256 | 257 | $response = $this->get('location.html'); 258 | 259 | $response->assertOk(); 260 | $this->assertSame('expected html response', $response->content()); 261 | } 262 | 263 | public function test_can_override_formats() 264 | { 265 | Route::get('location', function () { 266 | return (new TestResponse)->withFormatOverrides(['text/csv' => 'json']); 267 | }); 268 | 269 | $response = $this->get('location', ['Accept' => 'text/csv']); 270 | 271 | $response->assertOk(); 272 | $this->assertSame('expected json response', $response->content()); 273 | } 274 | } 275 | 276 | class TestResponse extends Response 277 | { 278 | public function toHtmlResponse() 279 | { 280 | return 'expected html response'; 281 | } 282 | 283 | public function toJsonResponse() 284 | { 285 | return 'expected json response'; 286 | } 287 | 288 | public function toCsvResponse() 289 | { 290 | return 'expected csv response'; 291 | } 292 | 293 | public function toXlsxResponse() 294 | { 295 | return 'expected xlsx response'; 296 | } 297 | } 298 | 299 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Multi-format Response Object for Laravel 2 | 3 | [![Latest Stable Version](https://poser.pugx.org/timacdonald/multiformat-response-objects/v/stable)](https://packagist.org/packages/timacdonald/multiformat-response-objects) [![Total Downloads](https://poser.pugx.org/timacdonald/multiformat-response-objects/downloads)](https://packagist.org/packages/timacdonald/multiformat-response-objects) [![License](https://poser.pugx.org/timacdonald/multiformat-response-objects/license)](https://packagist.org/packages/timacdonald/multiformat-response-objects) 4 | 5 | In some situations you may want to support multiple return formats (HTML, JSON, CSV, XLSX) for the one endpoint and controller. This package gives you a base class that helps you return different formats of the same data. It supports specifying the return format as a file extension or as an `Accept` header. It also allows you to have shared and format specific logic, all while sharing the same route and controller. 6 | 7 | ## Installation 8 | 9 | You can install using [composer](https://getcomposer.org/) from [Packagist](https://packagist.org/packages/timacdonald/multiformat-response-objects) 10 | 11 | ``` 12 | $ composer require timacdonald/multiformat-response-objects 13 | ``` 14 | 15 | ## Getting started 16 | 17 | This package is designed to help if you have ever created a controller that looks like this... 18 | 19 | ```php 20 | class UserController 21 | { 22 | public function index(Request $request, CsvWriter $csvWriter) 23 | { 24 | // some shared logic... 25 | 26 | $query = User::query() 27 | ->whereActive() 28 | ->whereStatus($request->query('status')); 29 | 30 | // format check(s) and format specific logic... 31 | 32 | if ($this->wantsCsv($request)) { 33 | 34 | // return a CSV... 35 | 36 | $query->each(function ($user) use ($csvWriter) { 37 | $csvWriter->addRow($user->only(['name', 'email'])); 38 | }); 39 | 40 | return response()->download($csvWriter->file(), "Users.csv", [ 41 | 'Content-type' => 'text/csv', 42 | ]); 43 | } 44 | 45 | // return a webpage... 46 | 47 | $memberships = Membership::all(); 48 | 49 | return view('users.index', [ 50 | 'memberships' => $memberships, 51 | 'users' => $this->query->paginate(), 52 | ]); 53 | } 54 | } 55 | ``` 56 | 57 | You might notice a few things about the above controller: 58 | 59 | 1. There is some initial shared logic between all the formats, i.e. preparing the query. 60 | 2. If the user is requesting the webpage, the `CsvWriter` is never used. 61 | 3. As we add more formats, we are going to no doubt be injecting more dependencies that are not needed in the other response types. 62 | 4. More response types also mean more checks in the `if` chain. 63 | 5. The web page also has format specific logic, i.e. it requires the `$memberships` collection, which is used (perhaps) to populate a dropdown on the webpage, but is not needed in the CSV download. 64 | 65 | This package cleans up this style of controller. Let me show you how... 66 | 67 | ### Cleaning up the controller 68 | 69 | The first step to refactoring the controller is to replace the format specific logic with the response object. You will no doubt do this step last, but I think it is easier to demonstrate it this way. 70 | 71 | ```php 72 | class UserController 73 | { 74 | public function index(Request $request, CsvWriter $csvWriter, ) 75 | { 76 | $query = User::query() 77 | ->whereActive() 78 | ->whereStatus($request->query('status')); 79 | 80 | return UserIndexResponse::make(['query' => $query]); 81 | } 82 | } 83 | ``` 84 | 85 | You can pass values into the response object by passing an array of data to the static `make` method. This is similar to how you may already be sending view data `view('users.index', ['some' => 'data'])`. 86 | 87 | ### The response object 88 | 89 | In order to support a particular response format, you need to add a corresponding response method. If you want to provide your blog posts in mp3 audio format, you would add a `toMp3Response` method to you response object. 90 | 91 | You can type hint these methods and the dependencies will be resolved from the container. In our example we are supporting HTML and CSV formats. 92 | 93 | ```php 94 | use TiMacDonald\MultiFormat\Response; 95 | 96 | class UserResponse extends Response 97 | { 98 | public function toCsvResponse(CsvWriter $writer) 99 | { 100 | $this->query->each(function ($user) use ($writer) { 101 | $writer->addRow($user->only(['name', 'email'])); 102 | }); 103 | 104 | return response()->download($writer->file(), "Users.csv", [ 105 | 'Content-type' => 'text/csv', 106 | ]); 107 | } 108 | 109 | public function toHtmlResponse() 110 | { 111 | $memberships = Membership::all(); 112 | 113 | return view('users.index', [ 114 | 'memberships' => $memberships, 115 | 'users' => $this->query->paginate(), 116 | ]); 117 | } 118 | } 119 | ``` 120 | 121 | You can see the `toCsvResponse` method has type hinted the `CsvWriter`. This dependency is only resolved when the request format is CSV. You can also magically access any of the data you passed into the `make` method as an attribute on the object e.g. `$this->query`. 122 | 123 | That is all there is to it really. Below are some more detailed docs and features. 124 | 125 | ## Detecting response format 126 | 127 | The response object will automatically detect the requested response format by checking for a file extension on the request's url and will fallback to the `Accept` header if no extension is found. Under the hood we are using Symfony's `MimeTypes` class to detect the extension. We then fallback to Laravel's `Request::format()` method. The first matching mime type and first matching extension will be used. 128 | 129 | You do not *have* to support file extensions. This is entirely in your control. If you only want to support the `Accept` header than set up your routing to not supportextensions. 130 | 131 | ### Why file extensions? 132 | 133 | It is pretty standard for an API to handle content negotiation with the `Accept` header. However it is often handy to be able to specify the response format with a file extension as well. This is probably most handy from a web interface where you can link the the same url but provide an extension to tell the server what format you want. 134 | 135 | ```html 136 |

Downloads

137 | 141 | ``` 142 | 143 | This pattern is used in a lot of places. A good example of this is Reddit. Append `.json` to any url on reddit and you will get a JSON formatted response. 144 | 145 | See for yourself: 146 | 147 | - [https://www.reddit.com/r/laravel](https://www.reddit.com/r/laravel) 148 | - [https://www.reddit.com/r/laravel.json](https://www.reddit.com/r/laravel.json) 149 | 150 | ## Response format methods 151 | 152 | In order to support a format, you create a `to{Format}Response` method, where `{Format}` is the formats file extension. e.g. 153 | 154 | - CSV: `toCsvResponse()` 155 | - JSON: `toJsonResponse()` 156 | - HTML: `toHtmlResponse()` 157 | - XLSX: `toXlsxResponse()` 158 | 159 | ### Dependency Injection 160 | 161 | As mentioned previously, the format method will be called by the container, allowing you to resolve **format specific dependencies** from the container. As seen in the basic usage example, the html format has no dependencies, however the csv format has a `CsvWriter` dependency. 162 | 163 | ## Default response format 164 | 165 | It is possible to set a default response format, either from the calling controller, or from within the response object itself. This default format will be used if the url and the `Accept` header have no set value, or if no matches are found against existing `Accept` types. 166 | 167 | ### In the controller 168 | 169 | ```php 170 | class UserController 171 | { 172 | public function index() 173 | { 174 | //... 175 | 176 | return UserResponse::make(['query' => $query]) 177 | ->withDefaultFormat('csv'); 178 | } 179 | } 180 | ``` 181 | 182 | ### In the response object 183 | 184 | ```php 185 | class UserResponse extends Response 186 | { 187 | protected $defaultFormat = 'csv'; 188 | 189 | // ... 190 | } 191 | ``` 192 | 193 | ## Overriding formats 194 | 195 | If there is a situation where the mime type you want to support is not being converted to the correct extension, either because it doesn't exist in the underlying libraries, or because it is matching the first extension and you want to use another, it is possible for you to manually specify overrides. 196 | 197 | Look at `audio/mpeg` for example. There are several extensions associated with this content type. 198 | 199 | ```php 200 | 'audio/mpeg' => ['mpga', 'mp2', 'mp2a', 'mp3', 'm2a', 'm3a'], 201 | ``` 202 | 203 | This package will resolve the first match, i.e. `mpga` as the format type. If you want to override this extension, you can do the following... 204 | 205 | ### In the controller 206 | 207 | ```php 208 | class UserController 209 | { 210 | public function index() 211 | { 212 | //... 213 | 214 | return UserResponse::make(['query' => $query]) 215 | ->withFormatOverrides([ 216 | 'audio/mpeg' => 'mp3', 217 | ]); 218 | } 219 | } 220 | ``` 221 | 222 | ### In the response object 223 | 224 | ```php 225 | class UserResponse extends Response 226 | { 227 | protected $formatOverrides = [ 228 | 'audio/mpeg' => 'mp3', 229 | ]; 230 | 231 | // ... 232 | } 233 | ``` 234 | 235 | The above would result in `toMp3Response` being called if the Accept header is `audio/mpeg`. 236 | 237 | ## Routing 238 | 239 | If you are wanting to embrace file extensions as a way of specifying response formats, you should explicilty specify the allowed formats in your routes file. This package does not provide any routing helpers (yet), but here is an example of how you can do it currently. 240 | 241 | ```php 242 | Route::get('users{extension?}', [ 243 | 'as' => 'users.index', 244 | 'uses' => 'UserController@index', 245 | // this is what we need to add... 246 | 'where' => [ 247 | 'extension' => '^\.(pdf|csv|xlsx)$', 248 | ], 249 | ]); 250 | ``` 251 | 252 | This route will be able to respond to the following urls and formats in the response object... 253 | 254 | - http://example.com/users [HTML] 255 | - http://example.com/users.pdf [PDF] 256 | - http://example.com/users.csv [CSV] 257 | - http://example.com/users.xlsx [XLSX] 258 | 259 | ## I hate magic 260 | 261 | That's cool. Not everyone loves it. You don't have to use the `make` method. Just add your own contructor and set your class attributes as you like! 262 | 263 | ```php 264 | class UserResponse extends Response 265 | { 266 | /** 267 | * @var \Illuminate\Database\Eloquent\Builder 268 | */ 269 | private $query; 270 | 271 | public function __construct(Builder $query) 272 | { 273 | $this->query = $query; 274 | } 275 | } 276 | 277 | //... 278 | 279 | return new UserResponse($query); 280 | ``` 281 | 282 | ## The Journey 283 | 284 | You've read the readme, you've seen the code, now read the journey. If you wanna see how I came to this solution, you can read my blog post: https://timacdonald.me/versatile-response-objects-laravel/. Warning: it's a bit of a rant. 285 | 286 | tl;dr; DHH and Adam Wathan are awesome. 287 | 288 | ## Thanksware 289 | 290 | You are free to use this package, but I ask that you reach out to someone (not me) who has previously, or is currently, maintaining or contributing to an open source library you are using in your project and thank them for their work. Consider your entire tech stack: packages, frameworks, languages, databases, operating systems, frontend, backend, etc. 291 | 292 | --------------------------------------------------------------------------------