Upgrade to Pro
— share decks privately, control downloads, hide ads and more …
Speaker Deck
Sign up for free
Menu
Search
Features
All features
Private URLs
Password Protection
Custom URLS
Scheduled publishing
Remove Branding
Restrict embedding
Deck Collections
Notes
Features
All features
Private URLs
Password Protection
Custom URLS
Scheduled publishing
Remove Branding
Restrict embedding
Deck Collections
Notes
Explore
Featured decks
Featured speakers
Programming
Technology
Storyboards
Explore
Featured decks
Featured speakers
Programming
Technology
Storyboards
Pricing
Search
Sign in
Sign up for free
APIs with Laravel - Laracon 2018
Search
Sponsored
·
SiteGround - Reliable hosting with speed, security, and support you can count on.
→
TJ
July 26, 2018
Programming
3.6k
6
Share
Embed
Copy iframe code
Copy JS code
Copy link
Start on current slide
APIs with Laravel - Laracon 2018
TJ
July 26, 2018
Other Decks in Programming
See All in Programming
Go × SIMDで高速化するベクトル検索 ~ルーフラインモデルでSIMDが効く境界を探れ! ~
po3rin
1
1.3k
ゲームコントローラやキーボードのファームウェアをSwiftで書く
kishikawakatsumi
1
240
AI が書く Go コードの品質を劇的に向上させる Linter: “declscope”
mpyw
0
350
FreeBSDでZabbixを動かす
kenkino
0
300
Are APIs Still Relevant in the AI Era?
soyuka
0
230
Omarchy Tokyo やると聞いて UMPC 買ってセットアップしてきた
mtsmfm
0
140
{ Android | Kotlin } Gradle Plugin in 2026
ryunen344
1
320
スマート反転とウェブアクセシビリティ
camiha
0
210
市販E-Readerを乗っ取れ 〜Embedded Swiftで電子ペーパーガジェットを制御する〜
trickart
0
190
kubernetes コンポーネント開発入門 / 新卒N年目の勉強会&交流会!〜〇〇への誘い〜 #n_study
mazrean
0
240
Starting & Sustaining Code-Based E2E Testing for Non-Coding QA Teams( #jasstniigata )
teyamagu
PRO
1
320
AWS Step Functions 大規模並列の壁を越える / jaws-sonic-2026-niigata-step-functions
kasacchiful
PRO
1
480
Featured
See All Featured
Sam Torres - BigQuery for SEOs
techseoconnect
PRO
0
540
Cheating the UX When There Is Nothing More to Optimize - PixelPioneers
stephaniewalter
287
14k
Large-scale JavaScript Application Architecture
addyosmani
515
110k
Intergalactic Javascript Robots from Outer Space
tanoku
273
27k
DevOps and Value Stream Thinking: Enabling flow, efficiency and business value
helenjbeal
1
390
Information Architects: The Missing Link in Design Systems
soysaucechin
1
1.1k
Building a A Zero-Code AI SEO Workflow
portentint
PRO
0
720
AI Search: Implications for SEO and How to Move Forward - #ShenzhenSEOConference
aleyda
1
1.4k
職位にかかわらず全員がリーダーシップを発揮するチーム作り / Building a team where everyone can demonstrate leadership regardless of position
madoxten
69
65k
Mind Mapping
helmedeiros
1
360
Claude Code のすすめ
schroneko
67
230k
エンジニアに許された特別な時間の終わり
watany
109
250k
Transcript
API DEVELOPMENT WITH LARAVEL @SLXLIV3
TJ MILLER (YEAH, I KNOW. NOT THE ACTOR) @SIXLIV3 TJMILLER.ME
@SLXLIV3
GOALS @SLXLIV3
PLANNING @SLXLIV3
FORMATTING @SLXLIV3
{ "data": [{ "type": "articles", "id": "1", "attributes": { "title":
"JSON API paints my bikeshed!", "body": "The shortest article. Ever.", "created": "2015-05-22T14:56:29.000Z", "updated": "2015-05-22T14:56:28.000Z" }, "relationships": { "author": { "data": {"id": "42", "type": "people"} } } }], "included": [ { "type": "people", "id": "42", "attributes": { "name": "John", "age": 80, "gender": "male" } } ] } @SLXLIV3
{ "class": [ "order" ], "properties": { "orderNumber": 42, "itemCount":
3, "status": "pending" }, "entities": [ { "class": [ "info", "customer" ], "rel": [ "http: //x.io/rels/customer" ], "properties": { "customerId": "pj123", "name": "Peter Joseph" }, "links": [ { "rel": [ "self" ], "href": "http: //api.x.io/customers/pj123" } ] } ], "actions": [ { "name": "add-item", "title": "Add Item", "method": "POST", "href": "http: //api.x.io/orders/42/items", "type": "application/x- www-form-urlencoded", "fields": [ { "name": "orderNumber", "type": "hidden", "value": "42" }, ] } ], "links": [ { "rel": [ "self" ], "href": "http: //api.x.io/orders/42" }, ] } @SLXLIV3
{ "status" : "success", "data" : { "posts" : [
{ "id" : 1, "title" : "A blog post", "body" : "Some useful content" }, { "id" : 2, "title" : "Another blog post", "body" : "More content" } ] } } @SLXLIV3
VERSIONING @SLXLIV3
VERSIONING @SLXLIV3
EVOLUTION @SLXLIV3
DOCUMENTATION @SLXLIV3
OPENAPI @SLXLIV3
openapi: 3.0.0 info: version: '2018-07-01' title: Laracon Blog license: name:
MIT servers: - url: 'https: //api.sixlabs.io' paths: /posts: get: summary: List all posts operationId: post.index tags: - Posts parameters: - in: query name: published schema: type: boolean responses: '200': description: A list of posts content: application/json: schema: $ref: 'schemas/post.index.json' default: description: unexpected error content: application/json: schema: $ref: 'schemas/error.json' @SLXLIV3
openapi: 3.0.0 info: version: '2018-07-01' title: Laracon Blog license: name:
MIT servers: - url: 'https: //api.sixlabs.io' paths: /posts: get: summary: List all posts operationId: post.index tags: - Posts parameters: - in: query name: published schema: type: boolean responses: '200': description: A list of posts content: application/json: schema: $ref: 'schemas/post.index.json' default: description: unexpected error content: application/json: schema: $ref: 'schemas/error.json' @SLXLIV3
openapi: 3.0.0 info: version: '2018-07-01' title: Laracon Blog license: name:
MIT components: securitySchemes: bearerAuth: type: http scheme: bearer security: - bearerAuth: [] servers: - url: 'https: //api.sixlabs.io' @SLXLIV3
openapi: 3.0.0 info: version: '2018-07-01' title: Laracon Blog license: name:
MIT servers: - url: 'https: //api.sixlabs.io' paths: /posts: get: summary: List all posts operationId: post.index tags: - Posts parameters: - in: query name: published schema: type: boolean responses: '200': description: A list of posts content: application/json: schema: $ref: 'schemas/post.index.json' default: description: unexpected error content: application/json: schema: $ref: 'schemas/error.json' @SLXLIV3
paths: /posts: get: summary: List all posts operationId: post.index tags:
- Posts parameters: - in: query name: published schema: type: boolean responses: '200': description: A list of posts content: application/json: schema: $ref: 'schemas/post.index.json' default: description: unexpected error content: application/json: schema: $ref: 'schemas/error.json' @SLXLIV3
openapi: 3.0.0 info: version: '2018-07-01' title: Laracon Blog license: name:
MIT servers: - url: 'https: //api.sixlabs.io' paths: /posts: get: summary: List all posts operationId: post.index tags: - Posts parameters: - in: query name: published schema: type: boolean responses: '200': description: A list of posts content: application/json: schema: $ref: 'schemas/post.index.json' default: description: unexpected error content: application/json: schema: $ref: 'schemas/error.json' @SLXLIV3
paths: /posts: get: summary: List all posts operationId: post.index tags:
- Posts parameters: - in: query name: published schema: type: boolean @SLXLIV3
paths: '/posts/{id}': get: summary: Details for a specific post operationId:
post.show tags: - Posts parameters: - name: id in: path required: true description: The id of the post to retrieve schema: type: string @SLXLIV3
openapi: 3.0.0 info: version: '2018-07-01' title: Laracon Blog license: name:
MIT servers: - url: 'https: //api.sixlabs.io' paths: /posts: get: summary: List all posts operationId: post.index tags: - Posts parameters: - in: query name: published schema: type: boolean responses: '200': description: A list of posts content: application/json: schema: $ref: 'schemas/post.index.json' default: description: unexpected error content: application/json: schema: $ref: 'schemas/error.json' @SLXLIV3
responses: '200': description: A list of posts content: application/json: schema:
$ref: 'schemas/post.index.json' default: description: unexpected error content: application/json: schema: $ref: 'schemas/error.json' @SLXLIV3
/posts: post: summary: Create posts operationId: post.create tags: - Posts
requestBody: required: true content: application/json: schema: $ref: 'schemas/post.create.json' responses: '201': description: Null response @SLXLIV3
JSON SCHEMA @SLXLIV3
responses: '200': description: A list of posts content: application/json: schema:
$ref: 'schemas/post.index.json' default: description: unexpected error content: application/json: schema: $ref: 'schemas/error.json' @SLXLIV3
schemas ├── error.json ├── post.create.json ├── post.index.json ├── post.json ├──
post.show.json ├── user.index.json ├── user.json └── user.show.json @SLXLIV3
{ "data": [ { "id": 1, "title": "Nova Introduction", "post_body":
"Zurfs Up !", "user_id": 1 }, { "id": 2, "title": "Nova Part Two", "post_body": null, "user_id": 1 } ] } @SLXLIV3
{ "properties": { "id": { "format": "int64", "type": "integer" },
"post_body": { "type": [ "string", "null" ] }, "title": { "type": [ "string", "null" ] }, "user_id": { "format": "int64", "type": "integer" } }, "required": [ "id", "user_id", "title", "post_body" ], "type": "object" } @SLXLIV3
{ "properties": { "id": { "format": "int64", "type": "integer" },
"post_body": { "type": [ "string", "null" ] } }, "required": [ "id", "post_body" ], "type": "object" } @SLXLIV3
{ "properties": { "data": { "items": { "$ref": "post.json" },
"type": "array" } }, "type": "object" } @SLXLIV3
├── app ├── artisan ├── bootstrap ├── composer.json ├── config
├── database ├── openapi.yml ├── package.json ├── phpunit.xml ├── public ├── resources ├── routes ├── schemas ├── server.php ├── storage ├── tags ├── tests ├── vendor └── webpack.mix.js @SLXLIV3
DOCUMENTATION GENERATION @SLXLIV3
@SLXLIV3
IMPLEMENTATION @SLXLIV3
LARAVEL VS LUMEN @SLXLIV3
SETUP @SLXLIV3
MIDDLEWARE @SLXLIV3
protected $middlewareGroups = [ 'web' => [ \App\Http\Middleware\EncryptCookies ::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse
::class, \Illuminate\Session\Middleware\StartSession ::class, // \Illuminate\Session\Middleware\AuthenticateSession ::class, \Illuminate\View\Middleware\ShareErrorsFromSession ::class, \App\Http\Middleware\VerifyCsrfToken ::class, \Illuminate\Routing\Middleware\SubstituteBindings ::class, ], 'api' => [ 'throttle:60,1', 'bindings', ], ]; @SLXLIV3
REMOVE MIDDLEWARE FILES └── Middleware ├── CheckForMaintenanceMode.php ├── EncryptCookies.php ├──
RedirectIfAuthenticated.php ├── TrimStrings.php ├── TrustProxies.php └── VerifyCsrfToken.php @SLXLIV3
app/Http/Kernel.php [ 'providers' => [ Illuminate\Cookie\CookieServiceProvider ::class, Illuminate\Session\SessionServiceProvider ::class, ],
'aliases' => [ 'Blade' => Illuminate\Support\Facades\Blade ::class, ], ] @SLXLIV3
ROUTE SERVICE PROVIDER app/Providers/RouteServiceProvider.php @SLXLIV3
public function map() { $this ->mapApiRoutes(); $this ->mapWebRoutes(); // }
@SLXLIV3
protected function mapApiRoutes() { Route ::prefix('api') ->middleware('api') ->namespace($this ->namespace) ->group(base_path('routes/api.php'));
} @SLXLIV3
protected function mapApiRoutes() { Route ::middleware('api') ->namespace($this ->namespace) ->group(base_path('routes/api.php')); }
@SLXLIV3
REMOVE WEB ROUTES FILE ├── routes │ ├── api.php │
├── channels.php │ ├── console.php │ └── web.php @SLXLIV3
public function map() { $this ->mapApiRoutes(); Route ::middleware('throttle:60,1') ->namespace($this ->namespace)
->group(function () { Route ::get('status', 'SystemStatusController'); }); } @SLXLIV3
app/Http/Controllers/SystemStatusController.php public function __invoke() { return response() ->json([ 'online' =>
true, 'version' => config('app.version'), ]); } @SLXLIV3
GET api.sixlabs.io/status { "online": true, "version": "2018-07-14" } @SLXLIV3
TESTING @SLXLIV3
├── tests │ ├── CreatesApplication.php │ ├── Feature │ │
└── UserApiTest.php │ └── TestCase.php @SLXLIV3
/** @test */ function fetch_a_user_index() { $user = factory(User ::class,
2) ->create(); } @SLXLIV3
/** @test */ function fetch_a_user_index() { $user = factory(User ::class,
2) ->create(); $this ->get(route('users.index')) ->assertOk(); } @SLXLIV3
Route ::get('users', 'UserController@index') ->name('users.index'); @SLXLIV3
class UserController extends Controller { public function index() { return
response(null, 200); } } @SLXLIV3
/** @test */ function fetch_a_user_index() { $user = factory(User ::class)
->create(); $this ->get(route('users.index')) ->assertOk() ->assertJsonFragment([ 'id' => $user ->id, 'name' => $user ->name, 'email' => $user ->email, ]); } @SLXLIV3
RESOURCE RESPONSES @SLXLIV3
=> App\User {#2898 name: "David Hemphill", email: "
[email protected]
", api_token: "edslIo7q1CW6",
updated_at: "2018-07-21 01:17:26", created_at: "2018-07-21 01:17:26", id: 1, } @SLXLIV3
class UserController extends Controller { public function index() { return
response() ->json([ 'data' => User ::all() ->toArray() ]); } } @SLXLIV3
{ "data": [{ "id": 1, "email": "
[email protected]
", "name": "David Hemphill",
"api_token": "edslIo7q1CW6", "created_at": "2018-07-21 01:17:26", "updated_at": "2018-07-21 01:17:26" }] } @SLXLIV3
class UserController extends Controller { public function index() { $users
= User ::select([ 'id', 'name', 'created_at', ]) ->get(); return response() ->json([ 'data' => $users, ]); } } @SLXLIV3
class UserController extends Controller { public function index() { $users
= User ::select([ 'id', 'name', 'created_at', ]) ->get(); return response() ->json([ 'data' => $users, ]); } public function show($id) { $user = User ::select([ 'id', 'name', 'created_at', ]) ->findOrFail($id); return response() ->json([ 'data' => $user, ]); } } @SLXLIV3
class PostController extends Controller { public function show($id) { $post
= Post ::with('author') ->findOrFail($id); // Formatting for Post AND User return response() ->json(['data' => $post]); } } @SLXLIV3
class UserResource extends JsonResource { public function toArray($request) { return
[ 'id' => $this ->id, 'email' => $this ->email, 'name' => $this ->name, 'created_at' => $this ->created_at, ]; } } @SLXLIV3
use App\Http\Resources\UserResource; class UserController extends Controller { public function index()
{ return UserResource ::collection(User ::all()); } public function show($id) { return UserResource ::make(User ::findOrFail($id)); } } @SLXLIV3
class PostResource extends JsonResource { public function toArray($request) { return
[ 'id' => $this ->id, 'title' => $this ->title, 'post_body' => $this ->post_body, 'author' => UserResource ::make($this ->whenLoaded('author')), ]; } } @SLXLIV3
class PostResource extends JsonResource { public function toArray($request) { return
[ 'id' => $this ->id, 'title' => $this ->title, 'post_body' => $this ->post_body, 'author' => UserResource ::make($this ->whenLoaded('author')), ]; } } @SLXLIV3
class PostController extends Controller { public function show($id) { return
PostResource ::make( Post ::with('author') ->findOrFail($id) ); } } @SLXLIV3
class UserResource extends JsonResource { public function toArray($request) { return
[ 'id' => $this ->id, 'email' => $this ->email, 'name' => $this ->name, 'api_token' => $this ->when( $this ->canViewApiToken(), $this ->api_token ), 'created_at' => $this ->created_at, ]; } private function canViewApiToken() { return auth() ->user() ->can('user.api_token.view', $this); } } @SLXLIV3
class UserResource extends JsonResource { public function toArray($request) { return
[ 'id' => $this ->id, 'email' => $this ->email, 'name' => $this ->name, 'api_token' => $this ->when( $this ->canViewApiToken(), $this ->access_token ), 'created_at' => $this ->created_at, ]; } } @SLXLIV3
class PostController extends Controller { public function index() { return
PostResource ::collection(Post ::all() ->paginate()); } } @SLXLIV3
{ "data": [ { "id": 1, "post_body": "Zurfs Up !",
"title": "Nova Introduction", "user_id": 1 }, { "id": 2, "post_body": null, "title": "Nova Part Two", "user_id": 1 } ], "links": { "first": "https: //api.sixlabs.io/posts?page=1", "last": "https: //api.sixlabs.io/posts?page=1", "next": null, "prev": null }, "meta": { "current_page": 1, "from": 1, "last_page": 1, "path": "https: //api.sixlabs.io/posts", "per_page": 15, "to": 1, "total": 1 } } @SLXLIV3
/** @test */ function fetch_a_user_index() { $user = factory(User ::class)
->create(); $this ->get(route('users.index')) ->assertOk() ->assertJsonFragment([ 'id' => $user ->id, 'name' => $user ->name, 'email' => $user ->email, ]) } @SLXLIV3
class UserResource extends JsonResource { public function toArray($request) { return
[ 'id' => $this ->id, 'email' => $this ->email, 'name' => $this ->name, 'created_at' => $this ->created_at, ]; } } @SLXLIV3
use App\Http\Resources\UserResource; class UserController extends Controller { public function index()
{ return UserResource ::collection(User ::all()); } } @SLXLIV3
/** @test */ function fetch_a_user_index() { $user = factory(User ::class)
->create(); $this ->get(route('users.index')) ->assertOk() ->assertJsonFragment([ 'id' => $user ->id, 'name' => $user ->name, 'email' => $user ->email, ]) } @SLXLIV3
/** @test */ function fetch_a_user_index() { $user = factory(User ::class)
->create(); $this ->get(route('users.index')) ->assertOk() ->assertJsonFragment([ 'id' => $user ->id, 'name' => $user ->name, 'email' => $user ->email, ]) ->assertJsonSchema('user.index'); } @SLXLIV3
sixlive/laravel-json-schema-assertions @SLXLIV3
AUTHENTICATION @SLXLIV3
GET /posts HTTP/2.0 Authorization: Bearer t82BJLrM3jONOXd7YnoKgSFNTlXuaQFZLTmmMsIIodsktiuVdMlmlFQGJtkduZhD Content-Type: application/json Host: api.sixlive.io
@SLXLIV3
class CreateUsersTable extends Migration { public function up() { Schema
::create('users', function (Blueprint $table) { $table ->increments('id'); $table ->string('name'); $table ->string('email') ->unique(); $table ->string('password'); $table ->string('api_token', 64); $table ->rememberToken(); $table ->timestamps(); }); } } @SLXLIV3
public static function boot() { parent ::boot(); static ::creating(function ($user)
{ $user ->api_token = str_random(64); }); } @SLXLIV3
'guards' => [ 'web' => [ 'driver' => 'session', 'provider'
=> 'users', ], 'api' => [ 'driver' => 'token', 'provider' => 'users', ], ], @SLXLIV3
'guards' => [ 'web' => [ 'driver' => 'token', 'provider'
=> 'users', ], 'api' => [ 'driver' => 'token', 'provider' => 'users', ], ], @SLXLIV3
Route ::get('posts', 'PostController@index') ->name('posts.index'); Route ::get('posts/{id}', 'PostController@show') ->name('posts.show'); Route ::middleware('auth')
->group(function () { Route ::post('posts', 'PostController@store') ->name('posts.store'); }); @SLXLIV3
/** @test */ function fields_are_required_to_create_a_post() { $user = factory(User ::class)
->create(); $this ->actingAs($user); $response = $this ->json('POST', route('posts.store'), []); $response ->assertStatus(Response ::HTTP_UNPROCESSABLE_ENTITY) ->assertJsonValidationErrors([ 'title', 'post_body' ]); } @SLXLIV3
public function store() { request() ->validate([ 'title' => 'required', 'post_body'
=> 'required', ]); } @SLXLIV3
/** @test */ public function a_user_can_create_a_post() { $user = factory(User
::class) ->create(); $this ->actingAs($user); $response = $this ->json('POST', route('posts.store'), [ 'title' => 'Laracon Spotlight: Wes Bos', 'post_body' => 'Wicked!' ]); $response ->assertStatus(Response ::HTTP_CREATED) ->assertJsonFragment([ 'post_body' => 'Wicked!', 'title' => 'Laracon Spotlight: Wes Bos', 'user_id' => $user ->id, ]) ->assertJsonSchema('post.show'); } @SLXLIV3
public function store() { request() ->validate([ 'title' => 'required', 'post_body'
=> 'required', ]); $post = request() ->user() ->posts() ->create( request() ->only(['title', 'post_body']) ); return PostResource ::make($post); } @SLXLIV3
spatie/laravel-query-builder @SLXLIV3
https: //localhost/posts?filter[published]=true /** @test */ public function can_filter_by_published() { $user
= factory(User ::class) ->create(); $posts = factory(Post ::class, 2) ->create([ 'user_id' => $user ->id, 'published' => true, ]); $posts[1] ->update(['published' => false]); $route = route('posts.index', [ 'filter' => [ 'published' => true ] ]); $this ->get($route) ->assertJsonCount(1, 'data') ->assertJsonFragment([ 'title' => $posts[0] ->title, 'post_body' => $posts[0] ->post_body, ]) ->assertJsonSchema('post.index'); } @SLXLIV3
https: //localhost/posts?filter[published]=true /** @test */ public function can_filter_by_published() { $user
= factory(User ::class) ->create(); $posts = factory(Post ::class, 2) ->create([ 'user_id' => $user ->id, 'published' => true, ]); $posts[1] ->update(['published' => false]); $route = route('posts.index', [ 'filter' => [ 'published' => true ] ]); $this ->get($route) ->assertJsonCount(1, 'data') ->assertJsonFragment([ 'title' => $posts[0] ->title, 'post_body' => $posts[0] ->post_body, ]) ->assertJsonSchema('post.index'); } @SLXLIV3
https: //localhost/posts?filter[published]=true /** @test */ public function can_filter_by_published() { $user
= factory(User ::class) ->create(); $posts = factory(Post ::class, 2) ->create([ 'user_id' => $user ->id, 'published' => true, ]); $posts[1] ->update(['published' => false]); $route = route('posts.index', [ 'filter' => [ 'published' => true ] ]); $this ->get($route) ->assertJsonCount(1, 'data') ->assertJsonFragment([ 'title' => $posts[0] ->title, 'post_body' => $posts[0] ->post_body, ]) ->assertJsonSchema('post.index'); } @SLXLIV3
https: //localhost/posts?filter[published]=true /** @test */ public function can_filter_by_published() { $user
= factory(User ::class) ->create(); $posts = factory(Post ::class, 2) ->create([ 'user_id' => $user ->id, 'published' => true, ]); $posts[1] ->update(['published' => false]); $route = route('posts.index', [ 'filter' => [ 'published' => true ] ]); $this ->get($route) ->assertJsonCount(1, 'data') ->assertJsonFragment([ 'title' => $posts[0] ->title, 'post_body' => $posts[0] ->post_body, ]) ->assertJsonSchema('post.index'); } @SLXLIV3
class PostController extends Controller { public function index() { $posts
= Post ::all(); return PostResource ::collection($posts); } } @SLXLIV3
use Spatie\QueryBuilder\QueryBuilder; class PostController extends Controller { public function index()
{ $posts = QueryBuilder ::for(Post ::class) ->allowedFilters('published') ->get(); return PostResource ::collection($posts); } } @SLXLIV3
RECOMMENDED PACKAGES ▸ Laravel CSP (spatie/laravel-csp) ▸ Laravel CORS (spatie/laravel-cors)
▸ Laravel Query Builder (spatie/laravel-query-builder) ▸ Bouncer (silber/bouncer) ▸ JSON Schema Assertions (sixlive/laravel-json-schema-assertions) @SLXLIV3
@SLXLIV3
APISWITHLARAVEL.COM @SLXLIV3
THANK YOU @SLXLIV3