Skip to content

Laravel Passport installation + Vuero integration

This is a tutorial, made by Denis Neagoe, for setting up a REST API endpoint for your Vuero project.

INFO

If you think your app can work with cookies instead of authentication by Bearer token, We recommend you that you to use Laravel Breeze instead.

Before getting started you might want to check the documentation of Laravel Passport as it might not serve your purpose. We recommend using Laravel Passport if you're going to allow your users to use OAuth2.

Everything in this page can be found on the Laravel official docs:

Laravel Passport Installation

  1. In order for you to start you will need Composer and you can download it from here: https://getcomposer.org/download/ (if the composer command doesn't work after your installation, you will have to manually add the envirnoment variable - windows: https://docs.oracle.com/en/database/oracle/machine-learning/oml4r/1.5.1/oread/creating-and-modifying-environment-variables-on-windows.html#GUID-DD6F9982-60D5-48F6-8270-A27EC53807D0)
  2. Proceeding, if you didn't encounter any problem you will need to install the Laravel installer using composer global require laravel/installer
  3. After installing the Laravel installer you will need to close and open again your terminal and check if the laravel command works by using it
  4. If everything went as planned you can now run laravel new backend and wait for the application to be created
  5. After running the command above, you will need to create a database either in MySQL, MongoDB, PostgreSQL, etc. in order to have it ready for the next steps
  6. You will now have to install the Passport scaffolding by using composer require laravel/passport and wait for the installation to conclude (the 5 points above are the same that are required for any Laravel installation in general, and were used in the Laravel Breeze API tutorial too)
  7. After assuring that your database details are correct in the environment file, you will now have to run your migrations using the artisan command php artisan migrate which will migrate all of Laravel's default migrations and the ones coming from Passport
  8. Now you will need to create the encrypted credentials used for grabbing an access token. You can do that by running php artisan passport:install

Handling authentication in Laravel

Since Laravel Passport doesn't offer any type of authentication system we will implement one that can is based on Laravel Breeze API's one, but first we'll make a change to the User model which you can find in App/Models/User.php

  1. You will need to add the HasApiTokens trait in order for your to access various model methods that come from Passport:

    php
    <?php
    
    namespace App\Models;
    
    use Illuminate\Database\Eloquent\Factories\HasFactory;
    use Illuminate\Foundation\Auth\User as Authenticatable;
    use Illuminate\Notifications\Notifiable;
    use Laravel\Passport\HasApiTokens;
    
    class User extends Authenticatable
    {
        use HasApiTokens, HasFactory, Notifiable;
    }
  2. To proceed, we'll head to the AuthServiceProvider at App\Providers\AuthServiceProvider.php In here we will need to change the boot method into:

    php
    public function boot()
    {
        $this->registerPolicies();
    
        if (!$this->app->routesAreCached()) {
            Passport::routes();
        }
    
        Passport::tokensExpireIn(now()->addMinutes(5));
        Passport::refreshTokensExpireIn(now()->addDays(7));
        Passport::personalAccessTokensExpireIn(now()->addDays(7));
    }

    What the code above will do is register Passport's routes and set tokens' expiration, in particular for the Authorization Token, Refresh Token and the Personal Access Token. We recommend to set your Token's expire time to a small amount to prevent hackers to steal the tokens from your users.

  3. In order for your routes to be protected, you will need to head to config/auth.php and edit your guards array to:

    php
    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
    
        'api' => [
            'driver' => 'passport',
            'provider' => 'users',
        ],
    ],

    From the docs

    This will instruct your application to use Passport's TokenGuard when authenticating incoming API requests.

    By now we will have a vary basic Laravel application, using Laravel Passport. Now let's make the authentication controllers to handle login & registering. If you haven't worked much with Laravel, We recommend you use the authentication system from Laravel Breeze API and the following controllers are taken from Breeze.

  4. Run php artisan make:controller Auth/AuthenticatedSessionController and edit the file to:

    php
    <?php
    
    namespace App\Http\Controllers\Auth;
    
    use App\Http\Controllers\Controller;
    use App\Http\Requests\Auth\LoginRequest;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\Auth;
    
    class AuthenticatedSessionController extends Controller
    {
        /**
         * Handle an incoming authentication request.
         *
         * @param  \App\Http\Requests\Auth\LoginRequest  $request
         * @return \Illuminate\Http\Response
         */
        public function store(LoginRequest $request)
        {
            $request->authenticate();
    
            $token = $request->user()->createToken('Auth Token')->accessToken;
    
            return response()->json($token);
        }
    
        /**
         * Destroy an authenticated session.
         *
         * @param  \Illuminate\Http\Request  $request
         * @return \Illuminate\Http\Response
         */
        public function destroy(Request $request)
        {
            $request->user()->token()->delete();
    
            Auth::guard('api')->logout();
    
            return response()->noContent();
        }
    }

    This controller will handle your login and logout process. In order for it to work, we will have to create a form request which enforce your input data's validation.

  5. Run php artisan make:request Auth/LoginRequest and edit the file to:

    php
    <?php
    
    namespace App\Http\Requests\Auth;
    
    use Illuminate\Auth\Events\Lockout;
    use Illuminate\Foundation\Http\FormRequest;
    use Illuminate\Support\Facades\Auth;
    use Illuminate\Support\Facades\RateLimiter;
    use Illuminate\Support\Str;
    use Illuminate\Validation\ValidationException;
    
    class LoginRequest extends FormRequest
    {
        /**
         * Determine if the user is authorized to make this request.
         *
         * @return bool
         */
        public function authorize()
        {
            return true;
        }
    
        /**
         * Get the validation rules that apply to the request.
         *
         * @return array
         */
        public function rules()
        {
            return [
                'email' => ['required', 'string', 'email'],
                'password' => ['required', 'string'],
            ];
        }
    
        /**
         * Attempt to authenticate the request's credentials.
         *
         * @return void
         *
         * @throws \Illuminate\Validation\ValidationException
         */
        public function authenticate()
        {
            $this->ensureIsNotRateLimited();
    
            if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
                RateLimiter::hit($this->throttleKey());
    
                throw ValidationException::withMessages([
                    'email' => __('auth.failed'),
                ]);
            }
    
            RateLimiter::clear($this->throttleKey());
        }
    
        /**
         * Ensure the login request is not rate limited.
         *
         * @return void
         *
         * @throws \Illuminate\Validation\ValidationException
         */
        public function ensureIsNotRateLimited()
        {
            if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
                return;
            }
    
            event(new Lockout($this));
    
            $seconds = RateLimiter::availableIn($this->throttleKey());
    
            throw ValidationException::withMessages([
                'email' => trans('auth.throttle', [
                    'seconds' => $seconds,
                    'minutes' => ceil($seconds / 60),
                ]),
            ]);
        }
    
        /**
         * Get the rate limiting throttle key for the request.
         *
         * @return string
         */
        public function throttleKey()
        {
            return Str::lower($this->input('email')).'|'.$this->ip();
        }
    }

    This form request will also handle your login throttle so a user can't make infinite calls to your endpoint.

    Now we will create the last controller which will handle the registration process.

  6. Run php artisan make:controller Auth/RegisteredUserController and edit the file to:

    php
    <?php
    
    namespace App\Http\Controllers\Auth;
    
    use App\Http\Controllers\Controller;
    use App\Models\User;
    use Illuminate\Auth\Events\Registered;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\Auth;
    use Illuminate\Support\Facades\Hash;
    use Illuminate\Validation\Rules;
    
    class RegisteredUserController extends Controller
    {
        /**
         * Handle an incoming registration request.
         *
         * @param  \Illuminate\Http\Request  $request
         * @return \Illuminate\Http\Response
         *
         * @throws \Illuminate\Validation\ValidationException
         */
        public function store(Request $request)
        {
            $request->validate([
                'username' => ['required', 'string', 'max:15', 'alpha_dash', 'unique:users'],
                'email' => ['required', 'string', 'email', 'max:50', 'unique:users', 'regex:/gmail|yahoo|outlook|hotmail|microsoft|protonmail]/'],
                'password' => ['required', 'confirmed', Rules\Password::defaults()],
            ]);
    
            $user = User::create([
                'username' => $request->username,
                'email' => $request->email,
                'password' => Hash::make($request->password),
            ]);
    
            event(new Registered($user));
    
            $token = $user->createToken('Auth Token')->accessToken;
    
            Auth::login($user);
    
            return response()->json($token);
        }
    }

    We have now finished with the routers and we can proceed with creating the required routes.

  7. We recommend you create a new file inside your routes folder and call it auth.php

  8. In order for your routes to appear in the routes list (php artisan route:list) you will need to create a new entry into your route service provider (App/Http/Providers/RouteServiceProvider.php):

    php
    Route::middleware('api')
        ->prefix('api')
        ->group(base_path('routes/auth.php'));

    This will register the routes inside of your auth.php file and will add the prefix api to your URL so you won't be needed to add a prefix.

  9. Now let's create the authentication routes inside of the routes/auth.php file:

    php
    <?php
    
    use App\Http\Controllers\Auth;
    use Illuminate\Support\Facades\Route;
    
    Route::post('/register', [Auth\RegisteredUserController::class, 'store']);
    
    Route::controller(Auth\RegisteredUserController::class)->group(function () {
        Route::post('/login', 'store');
        Route::post('/logout', 'destroy')->middleware('auth:api');
    });

    The Route::controller will group both routes since they use the same controller.

  10. Now you can check if the routes exist and to do so, you can run the artisan command php artisan route:list. If your routes didn't update you can run php artisan optimize

INFO

Everything is now set and you can serve your application with the artisan command php artisan serve

Vuero changes

  1. You can now delete the json-server dependency from your packages.json as you won't need it anymore since you've create a new backend application
  2. Head into your .env file and change the VITE_API_BASE_URL to VITE_API_BASE_URL=http://localhost:8000 or simply edit the port to 8000 as it's the default port Laravel serves on
  3. Now you will have your apps connected through the 8000 and 3000 ports

API composable changes

In order for your Vuero application to work you can create useLaravelFetch composable with ofetch:

Create src/composable/useLaravelFetch.ts and add the following code:

ts
import { ofetch } from 'ofetch'

import { useUserSession } from '/@src/stores/userSession'

export function useLaravelFetch() {
  const userSession = useUserSession()

  return ofetch.create({
    baseURL: import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000',
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json',
      'X-Requested-With': 'XMLHttpRequest',
    },
    // We set an interceptor for each request to
    // include Bearer token to the request if user is logged in
    onRequest: ({ options }) => {
      if (userSession.isLoggedIn) {
        options.headers = {
          ...options.headers,
          Authorization: `Bearer ${userSession.token}`,
        }
      }
    },
  })
}

This function will set your default endpoint to the one in the .env file and will add the X-Requested-With and Content-Type headers as well as the credentials: 'include' property.

Catching back-end errors

In order to catch back-end errors we will make a new file composable called useLaravelError and we will add it into src/composable/useLaravelError.ts with the following code:

ts
export const useLaravelError = (err: any) => {
  if (err.response?.status === undefined) {
    if (err.message == 'Network Error') return 'Connection error! Please try again later'
    else return err.message
  }

  let message
  switch (err.response?.status) {
    case 401:
      message = 'auth.errors.unauthenticated'

      break
    case 403:
      message = "You don't have the permission to see this page!"

      break
    case 422:
      message = Object.values<string>(err.response.data?.errors)?.flat()?.[0]

      break
    case 500:
      message = 'Internal error! Please try again later'

      break
  }
  return message
}

Catching field errors

As you will see below, in order to catch field errors and display them, we will need a wrapper for the setFieldError function. To create the wrapper, head to /src/utils/api/ and create a new file called catchFormErrors.ts and add the following code:

ts
export function catchFieldError(err: any, setFieldError: any) {
  if (err.response?.status === 422) {
    const errors = err.response.data?.errors
    for (const field in errors) {
      setFieldError(field, errors[field][0])
    }
  }
}

The previous function will iterate through your field errors and it will add the error message under each specific field.

UserSession store

In order for your user's session to be saved in the pinia store, you will have to make a few changes to the src/store/UserSession.ts composable:

ts
export interface User {
  id: number
  username: string
  email: string
}

import { acceptHMRUpdate, defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useStorage } from '@vueuse/core'

import type { User } from '/@src/models/user'

export const useUserSession = defineStore('userSession', () => {
  // token will be synced with local storage
  // @see https://vueuse.org/core/usestorage/
  const token = useStorage('token', '')

  const user = ref<Partial<User>>()
  const loading = ref(true)

  const isLoggedIn = computed(() => token.value !== undefined && token.value !== '')

  function setUser(newUser: Partial<User>) {
    user.value = newUser
  }

  function setToken(newToken: string) {
    token.value = newToken
  }

  function setLoading(newLoading: boolean) {
    loading.value = newLoading
  }

  async function logoutUser() {
    token.value = undefined
    user.value = undefined
  }

  return {
    user,
    token,
    isLoggedIn,
    loading,
    logoutUser,
    setUser,
    setToken,
    setLoading,
  } as const
})

/**
 * Pinia supports Hot Module replacement so you can edit your stores and
 * interact with them directly in your app without reloading the page.
 *
 * @see https://pinia.esm.dev/cookbook/hot-module-replacement.html
 * @see https://vitejs.dev/guide/api-hmr.html
 */
if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useUserSession, import.meta.hot))
}

Note that We used Partial for the data type because in my case I had more fillables than the ones I put in the snippet so take into consideration that you will probably need to change it.

Authentication service

In order to have most of the processes organized, I advice you create a services directory inside of the src directory and furthermore a modules directory inside services. I personally keep the services separated so you can create another directory called auth and inside of it you can create a file called accounts.ts in which you can paste:

ts
import { useLaravelFetch } from '/@src/composable/useLaravelFetch'
import { useUserSession } from '/@src/stores/userSession'

export async function authenticateUser(route: string, body: Record<string, any>) {
  const $fetch = useLaravelFetch()
  const userSession = useUserSession()

  const token = await $fetch(route, {
    method: 'POST',
    body,
  })
  userSession.setToken(token)

  const user = await $fetch('/user')
  userSession.setUser(user.data)
}

Here you can add your logic for further authentication processes such as password reset or anything else you wish.

INFO

In order to populate the userSession store, we will need to return some data from our back-end

Run php artisan make:resource UserResource and add the following code:

php
<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
     */
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'username' => $this->username,
            'email' => $this->email,
            'email_verified_at' => $this->email_verified_at,
            'created' => $this->created_at,
        ];
    }
}

This code will allow you to get your user's data and save it in the Pinia store.

Login page changes

As of now we have connected both the Laravel and Vuero apps so we can change the login method too. I made a few changes to the original file and I imported yup for data validation and We used vue-i18n for the translations so you will need to create them inside of the JSON file.

Head to src/pages/auth/login.vue and change your login function to:

ts
import { useI18n } from 'vue-i18n'

import { toFormValidator } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { z as zod } from 'zod'

import { useDarkmode } from '/@src/stores/darkmode'
import { useNotyf } from '/@src/composable/useNotyf'
import { useLaravelError } from '/@src/composable/useLaravelError'

import { useUserSession } from '/@src/stores/userSession'
import { authenticateUser } from '/@src/services/modules/user/account'

import { catchFieldError } from '/@src/utils/api/catchFieldError'

const darkmode = useDarkmode()
const router = useRouter()
const route = useRoute()
const notyf = useNotyf()
const userSession = useUserSession()
const redirect = route.query.redirect as string

const isLoading = ref(false)
const { t } = useI18n()

// Define a validation schema
const validationSchema = toFormValidator(
  zod.object({
    email: zod
      .string({
        required_error: t('auth.errors.email.required'),
      })
      .email(t('auth.errors.email.format')),
    password: zod.string({
      required_error: t('auth.errors.password.required'),
    }),
    remember: zod.boolean().optional(),
  })
)

const { handleSubmit, isSubmitting, setFieldError } = useForm({
  validationSchema,
  initialValues: {
    email: '',
    password: '',
    remember: false,
  },
})

const onLogin = handleSubmit(async (values) => {
  if (!isLoading.value) {
    isLoading.value = true

    try {
      await authenticateUser('/login', values)

      if (redirect) {
        router.push(redirect)
      } else {
        router.push('/')
      }

      notyf.dismissAll()
      notyf.success(`${t('auth.logged-in')}, ${userSession.user!.username}`)
    } catch (err: any) {
      catchFieldError(err, setFieldError)
      notyf.error(useLaravelError(err))
    } finally {
      isLoading.value = false
    }
  }
})

useHead({
  title: 'Login - Vuero',
})

Register page changes

The registration process is similar to the login process:

Head to src/pages/auth/register.vue and change your register function to:

ts
import { useI18n } from 'vue-i18n'

import { toFormValidator } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { z as zod } from 'zod'

import { useDarkmode } from '/@src/stores/darkmode'
import { useNotyf } from '/@src/composable/useNotyf'
import { useLaravelError } from '/@src/composable/useLaravelError'

import { useUserSession } from '/@src/stores/userSession'
import { authenticateUser } from '/@src/services/modules/user/account'

import { catchFieldError } from '/@src/utils/api/catchFieldError'

const darkmode = useDarkmode()
const router = useRouter()
const route = useRoute()
const notyf = useNotyf()
const userSession = useUserSession()
const redirect = route.query.redirect as string

const isLoading = ref(false)
const { t } = useI18n()

const validationSchema = toFormValidator(
  zod
    .object({
      username: zod
        .string({
          required_error: t('auth.errors.username.required'),
        })
        .min(1, t('auth.errors.username.required')),
      email: zod
        .string({
          required_error: t('auth.errors.email.required'),
        })
        .email(t('auth.errors.email.format')),
      password: zod
        .string({
          required_error: t('auth.errors.password.required'),
        })
        .min(8, t('auth.errors.password.length')),
      password_confirmation: zod.string({
        required_error: t('auth.errors.password_confirmation.required'),
      }),
    })
    .refine((data) => data.password === data.password_confirmation, {
      message: t('auth.errors.password_confirmation.match'),
      path: ['password_confirmation'],
    })
)

const { handleSubmit, isSubmitting, setFieldError } = useForm({
  validationSchema,
  initialValues: {
    username: '',
    email: '',
    password: '',
    password_confirmation: '',
  },
})

const onRegister = handleSubmit(async (values) => {
  if (!isLoading.value) {
    isLoading.value = true

    try {
      await authenticateUser('/register', values)

      if (redirect) {
        router.push(redirect)
      } else {
        router.push('/')
      }

      notyf.dismissAll()
      notyf.success(`${t('auth.registered')}, ${userSession.user!.username}`)
    } catch (err: any) {
      catchFieldError(err, setFieldError)
      notyf.error(useLaravelError(err))
    } finally {
      isLoading.value = false
    }
  }
})

useHead({
  title: 'Register - Vuero',
})

And this is an example of how a field should look like for both authentication pages (using login-1 and signup-1 templates):

html
<VField id="email" v-slot="{ field }">
  <VControl>
    <VInput type="email" class="input" :placeholder="t('auth.placeholder.email')" autocomplete="email" :disabled="isSubmitting" />
    <div class="auth-label">{{ t('auth.placeholder.email') }}</div>
    <div class="autv-icon">
      <i aria-hidden="true" class="lnil lnil-envelope"></i>
    </div>
    <p v-if="field?.errors?.value?.length" class="help is-danger">{{ field.errors?.value?.join(', ') }}</p>
  </VControl>
</VField>

Notes

  • Every time you will need to work on your Vuero application or Laravel application you will need to run the artisan command php artisan serve, otherwise you will receive a 404 error.

All Rights Reserved