Build PHP apps with Microsoft Graph
This tutorial teaches you how to build a PHP web app that uses the Microsoft Graph API to retrieve calendar information for a user.
Tip
If you prefer to just download the completed tutorial, you can download it in two ways.
- Download the PHP quick start to get working code in minutes.
- Download or clone the GitHub repository.
Prerequisites
Before you start this tutorial, you should have PHP, Composer, and Laravel installed on your development machine.
You should also have either a personal Microsoft account with a mailbox on Outlook.com, or a Microsoft work or school account. If you don't have a Microsoft account, there are a couple of options to get a free account:
- You can sign up for a new personal Microsoft account.
- You can sign up for the Office 365 Developer Program to get a free Office 365 subscription.
Note
This tutorial was written with PHP version 8.0.1, Composer version 2.0.8, and Laravel installer version 4.1.1. The steps in this guide may work with other versions, but that has not been tested.
Feedback
Please provide any feedback on this tutorial in the GitHub repository.
Create a PHP web app
Begin by creating a new Laravel project.
Open your command-line interface (CLI), navigate to a directory where you have rights to create files, and run the following command to create a new PHP app.
laravel new graph-tutorialNavigate to the graph-tutorial directory and enter the following command to start a local web server.
php artisan serveOpen your browser and navigate to
http://localhost:8000. If everything is working, you will see a default Laravel page. If you don't see that page, check the Laravel docs.
Install packages
Before moving on, install some additional packages that you will use later:
- oauth2-client for handling sign-in and OAuth token flows.
- microsoft-graph for making calls to Microsoft Graph.
Run the following command in your CLI.
composer require league/oauth2-client microsoft/microsoft-graph
Design the app
Create a new file in the ./resources/views directory named
layout.blade.phpand add the following code.<!DOCTYPE html> <html> <head> <title>PHP Graph Tutorial</title> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous"> <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.13.0/css/all.css"> <link rel="stylesheet" href="{{ asset('/css/app.css') }}"> </head> <body> <nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark"> <div class="container"> <a href="/" class="navbar-brand">PHP Graph Tutorial</a> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="navbarCollapse"> <ul class="navbar-nav mr-auto"> <li class="nav-item"> <a href="/" class="nav-link {{$_SERVER['REQUEST_URI'] == '/' ? ' active' : ''}}">Home</a> </li> @if(isset($userName)) <li class="nav-item" data-turbolinks="false"> <a href="/calendar" class="nav-link{{$_SERVER['REQUEST_URI'] == '/calendar' ? ' active' : ''}}">Calendar</a> </li> @endif </ul> <ul class="navbar-nav justify-content-end"> <li class="nav-item"> <a class="nav-link" href="https://docs.microsoft.com/graph/overview" target="_blank"> <i class="fas fa-external-link-alt mr-1"></i>Docs </a> </li> @if(isset($userName)) <li class="nav-item dropdown"> <a class="nav-link dropdown-toggle" data-toggle="dropdown" href="#" role="button" aria-haspopup="true" aria-expanded="false"> @if(isset($user_avatar)) <img src="{{ $user_avatar }}" class="rounded-circle align-self-center mr-2" style="width: 32px;"> @else <i class="far fa-user-circle fa-lg rounded-circle align-self-center mr-2" style="width: 32px;"></i> @endif </a> <div class="dropdown-menu dropdown-menu-right"> <h5 class="dropdown-item-text mb-0">{{ $userName }}</h5> <p class="dropdown-item-text text-muted mb-0">{{ $userEmail }}</p> <div class="dropdown-divider"></div> <a href="/signout" class="dropdown-item">Sign Out</a> </div> </li> @else <li class="nav-item"> <a href="/signin" class="nav-link">Sign In</a> </li> @endif </ul> </div> </div> </nav> <main role="main" class="container"> @if(session('error')) <div class="alert alert-danger" role="alert"> <p class="mb-3">{{ session('error') }}</p> @if(session('errorDetail')) <pre class="alert-pre border bg-light p-2"><code>{{ session('errorDetail') }}</code></pre> @endif </div> @endif @yield('content') </main> <script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script> <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" crossorigin="anonymous"></script> </body> </html>This code adds Bootstrap for simple styling, and Font Awesome for some simple icons. It also defines a global layout with a nav bar.
Create a new directory in the
./publicdirectory namedcss, then create a new file in the./public/cssdirectory namedapp.css. Add the following code.body { padding-top: 4.5rem; } .alert-pre { word-wrap: break-word; word-break: break-all; white-space: pre-wrap; }Open the
./resources/views/welcome.blade.phpfile and replace its contents with the following.@extends('layout') @section('content') <div class="jumbotron"> <h1>PHP Graph Tutorial</h1> <p class="lead">This sample app shows how to use the Microsoft Graph API to access a user's data from PHP</p> @if(isset($userName)) <h4>Welcome {{ $userName }}!</h4> <p>Use the navigation bar at the top of the page to get started.</p> @else <a href="/signin" class="btn btn-primary btn-large">Click here to sign in</a> @endif </div> @endsectionUpdate the base
Controllerclass in ./app/Http/Controllers/Controller.php by adding the following function to the class.public function loadViewData() { $viewData = []; // Check for flash errors if (session('error')) { $viewData['error'] = session('error'); $viewData['errorDetail'] = session('errorDetail'); } // Check for logged on user if (session('userName')) { $viewData['userName'] = session('userName'); $viewData['userEmail'] = session('userEmail'); $viewData['userTimeZone'] = session('userTimeZone'); } return $viewData; }Create a new file in the
./app/Http/Controllersdirectory namedHomeController.phpand add the following code.<?php // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. namespace App\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\Request; class HomeController extends Controller { public function welcome() { $viewData = $this->loadViewData(); return view('welcome', $viewData); } }Update the route in
./routes/web.phpto use the new controller. Replace the entire contents of this file with the following.<?php use Illuminate\Support\Facades\Route; Route::get('/', 'HomeController@welcome');Open ./app/Providers/RouteServiceProvider.php and uncomment the
$namespacedeclaration./** * This namespace is applied to your controller routes. * * In addition, it is set as the URL generator's root namespace. * * @var string */ protected $namespace = 'App\Http\Controllers';Save all of your changes and restart the server. Now, the app should look very different.

Register the app in the portal
In this exercise, you will create a new Azure AD web application registration using the Azure Active Directory admin center.
Open a browser and navigate to the Azure Active Directory admin center. Login using a personal account (aka: Microsoft Account) or Work or School Account.
Select Azure Active Directory in the left-hand navigation, then select App registrations under Manage.

Select New registration. On the Register an application page, set the values as follows.
- Set Name to
PHP Graph Tutorial. - Set Supported account types to Accounts in any organizational directory and personal Microsoft accounts.
- Under Redirect URI, set the first drop-down to
Weband set the value tohttp://localhost:8000/callback.

- Set Name to
Select Register. On the PHP Graph Tutorial page, copy the value of the Application (client) ID and save it, you will need it in the next step.

Select Certificates & secrets under Manage. Select the New client secret button. Enter a value in Description and select one of the options for Expires and select Add.

Copy the client secret value before you leave this page. You will need it in the next step.
Important
This client secret is never shown again, so make sure you copy it now.

Add Azure AD authentication
In this exercise you will extend the application from the previous exercise to support authentication with Azure AD. This is required to obtain the necessary OAuth access token to call the Microsoft Graph. In this step you will integrate the oauth2-client library into the application.
Open the .env file in the root of your PHP application, and add the following code to the end of the file.
OAUTH_APP_ID=YOUR_APP_ID_HERE OAUTH_APP_SECRET=YOUR_APP_SECRET_HERE OAUTH_REDIRECT_URI=http://localhost:8000/callback OAUTH_SCOPES='openid profile offline_access user.read mailboxsettings.read calendars.readwrite' OAUTH_AUTHORITY=https://login.microsoftonline.com/common OAUTH_AUTHORIZE_ENDPOINT=/oauth2/v2.0/authorize OAUTH_TOKEN_ENDPOINT=/oauth2/v2.0/tokenReplace
YOUR_APP_ID_HEREwith the application ID from the Application Registration Portal, and replaceYOUR_APP_SECRET_HEREwith the password you generated.Important
If you're using source control such as git, now would be a good time to exclude the
.envfile from source control to avoid inadvertently leaking your app ID and password.Create a new file in the ./config folder named
azure.phpand add the following code.<?php // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. // Access environment through the config helper // This will avoid issues when using Laravel's config caching // https://laravel.com/docs/8.x/configuration#configuration-caching return [ 'appId' => env('OAUTH_APP_ID', ''), 'appSecret' => env('OAUTH_APP_SECRET', ''), 'redirectUri' => env('OAUTH_REDIRECT_URI', ''), 'scopes' => env('OAUTH_SCOPES', ''), 'authority' => env('OAUTH_AUTHORITY', 'https://login.microsoftonline.com/common'), 'authorizeEndpoint' => env('OAUTH_AUTHORIZE_ENDPOINT', '/oauth2/v2.0/authorize'), 'tokenEndpoint' => env('OAUTH_TOKEN_ENDPOINT', '/oauth2/v2.0/token'), ];
Implement sign-in
Create a new file in the ./app/Http/Controllers directory named
AuthController.phpand add the following code.<?php namespace App\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\Request; class AuthController extends Controller { public function signin() { // Initialize the OAuth client $oauthClient = new \League\OAuth2\Client\Provider\GenericProvider([ 'clientId' => config('azure.appId'), 'clientSecret' => config('azure.appSecret'), 'redirectUri' => config('azure.redirectUri'), 'urlAuthorize' => config('azure.authority').config('azure.authorizeEndpoint'), 'urlAccessToken' => config('azure.authority').config('azure.tokenEndpoint'), 'urlResourceOwnerDetails' => '', 'scopes' => config('azure.scopes') ]); $authUrl = $oauthClient->getAuthorizationUrl(); // Save client state so we can validate in callback session(['oauthState' => $oauthClient->getState()]); // Redirect to AAD signin page return redirect()->away($authUrl); } public function callback(Request $request) { // Validate state $expectedState = session('oauthState'); $request->session()->forget('oauthState'); $providedState = $request->query('state'); if (!isset($expectedState)) { // If there is no expected state in the session, // do nothing and redirect to the home page. return redirect('/'); } if (!isset($providedState) || $expectedState != $providedState) { return redirect('/') ->with('error', 'Invalid auth state') ->with('errorDetail', 'The provided auth state did not match the expected value'); } // Authorization code should be in the "code" query param $authCode = $request->query('code'); if (isset($authCode)) { // Initialize the OAuth client $oauthClient = new \League\OAuth2\Client\Provider\GenericProvider([ 'clientId' => config('azure.appId'), 'clientSecret' => config('azure.appSecret'), 'redirectUri' => config('azure.redirectUri'), 'urlAuthorize' => config('azure.authority').config('azure.authorizeEndpoint'), 'urlAccessToken' => config('azure.authority').config('azure.tokenEndpoint'), 'urlResourceOwnerDetails' => '', 'scopes' => config('azure.scopes') ]); try { // Make the token request $accessToken = $oauthClient->getAccessToken('authorization_code', [ 'code' => $authCode ]); // TEMPORARY FOR TESTING! return redirect('/') ->with('error', 'Access token received') ->with('errorDetail', $accessToken->getToken()); } catch (League\OAuth2\Client\Provider\Exception\IdentityProviderException $e) { return redirect('/') ->with('error', 'Error requesting access token') ->with('errorDetail', $e->getMessage()); } } return redirect('/') ->with('error', $request->query('error')) ->with('errorDetail', $request->query('error_description')); } }This defines a controller with two actions:
signinandcallback.The
signinaction generates the Azure AD signin URL, saves thestatevalue generated by the OAuth client, then redirects the browser to the Azure AD signin page.The
callbackaction is where Azure redirects after the signin is complete. That action makes sure thestatevalue matches the saved value, then users the authorization code sent by Azure to request an access token. It then redirects back to the home page with the access token in the temporary error value. You'll use this to verify that sign-in is working before moving on.Add the routes to ./routes/web.php.
Route::get('/signin', 'AuthController@signin'); Route::get('/callback', 'AuthController@callback');Start the server and browse to
https://localhost:8000. Click the sign-in button and you should be redirected tohttps://login.microsoftonline.com. Login with your Microsoft account.Examine the consent prompt. The list of permissions correspond to list of permissions scopes configured in .env.
- Maintain access to data you have given it access to: (
offline_access) This permission is requested by MSAL in order to retrieve refresh tokens. - Sign you in and read your profile: (
User.Read) This permission allows the app to get the logged-in user's profile and profile photo. - Read your mailbox settings: (
MailboxSettings.Read) This permission allows the app to read the user's mailbox settings, including time zone and time format. - Have full access to your calendars: (
Calendars.ReadWrite) This permission allows the app to read events on the user's calendar, add new events, and modify existing ones.
- Maintain access to data you have given it access to: (
Consent to the requested permissions. The browser redirects to the app, showing the token.
Get user details
In this section you'll update the callback method to get the user's profile from Microsoft Graph.
Add the following
usestatements to the top of /app/Http/Controllers/AuthController.php, beneath thenamespace App\Http\Controllers;line.use Microsoft\Graph\Graph; use Microsoft\Graph\Model;Replace the
tryblock in thecallbackmethod with the following code.try { // Make the token request $accessToken = $oauthClient->getAccessToken('authorization_code', [ 'code' => $authCode ]); $graph = new Graph(); $graph->setAccessToken($accessToken->getToken()); $user = $graph->createRequest('GET', '/me?$select=displayName,mail,mailboxSettings,userPrincipalName') ->setReturnType(Model\User::class) ->execute(); // TEMPORARY FOR TESTING! return redirect('/') ->with('error', 'Access token received') ->with('errorDetail', 'User:'.$user->getDisplayName().', Token:'.$accessToken->getToken()); }
The new code creates a Graph object, assigns the access token, then uses it to request the user's profile. It adds the user's display name to the temporary output for testing.
Storing the tokens
Now that you can get tokens, it's time to implement a way to store them in the app. Since this is a sample app, for simplicity's sake, you'll store them in the session. A real-world app would use a more reliable secure storage solution, like a database.
Create a new directory in the ./app directory named
TokenStore, then create a new file in that directory namedTokenCache.php, and add the following code.<?php namespace App\TokenStore; class TokenCache { public function storeTokens($accessToken, $user) { session([ 'accessToken' => $accessToken->getToken(), 'refreshToken' => $accessToken->getRefreshToken(), 'tokenExpires' => $accessToken->getExpires(), 'userName' => $user->getDisplayName(), 'userEmail' => null !== $user->getMail() ? $user->getMail() : $user->getUserPrincipalName(), 'userTimeZone' => $user->getMailboxSettings()->getTimeZone() ]); } public function clearTokens() { session()->forget('accessToken'); session()->forget('refreshToken'); session()->forget('tokenExpires'); session()->forget('userName'); session()->forget('userEmail'); session()->forget('userTimeZone'); } public function getAccessToken() { // Check if tokens exist if (empty(session('accessToken')) || empty(session('refreshToken')) || empty(session('tokenExpires'))) { return ''; } return session('accessToken'); } }Add the following
usestatement to the top of ./app/Http/Controllers/AuthController.php, beneath thenamespace App\Http\Controllers;line.use App\TokenStore\TokenCache;Replace the
tryblock in the existingcallbackfunction with the following.try { // Make the token request $accessToken = $oauthClient->getAccessToken('authorization_code', [ 'code' => $authCode ]); $graph = new Graph(); $graph->setAccessToken($accessToken->getToken()); $user = $graph->createRequest('GET', '/me?$select=displayName,mail,mailboxSettings,userPrincipalName') ->setReturnType(Model\User::class) ->execute(); $tokenCache = new TokenCache(); $tokenCache->storeTokens($accessToken, $user); return redirect('/'); }
Implement sign-out
Before you test this new feature, add a way to sign out.
Add the following action to the
AuthControllerclass.public function signout() { $tokenCache = new TokenCache(); $tokenCache->clearTokens(); return redirect('/'); }Add this action to ./routes/web.php.
Route::get('/signout', 'AuthController@signout');Restart the server and go through the sign-in process. You should end up back on the home page, but the UI should change to indicate that you are signed-in.

Click the user avatar in the top right corner to access the Sign Out link. Clicking Sign Out resets the session and returns you to the home page.

Refreshing tokens
At this point your application has an access token, which is sent in the Authorization header of API calls. This is the token that allows the app to access the Microsoft Graph on the user's behalf.
However, this token is short-lived. The token expires an hour after it is issued. This is where the refresh token becomes useful. The refresh token allows the app to request a new access token without requiring the user to sign in again. Update the token management code to implement token refresh.
Open ./app/TokenStore/TokenCache.php and add the following function to the
TokenCacheclass.public function updateTokens($accessToken) { session([ 'accessToken' => $accessToken->getToken(), 'refreshToken' => $accessToken->getRefreshToken(), 'tokenExpires' => $accessToken->getExpires() ]); }Replace the existing
getAccessTokenfunction with the following.public function getAccessToken() { // Check if tokens exist if (empty(session('accessToken')) || empty(session('refreshToken')) || empty(session('tokenExpires'))) { return ''; } // Check if token is expired //Get current time + 5 minutes (to allow for time differences) $now = time() + 300; if (session('tokenExpires') <= $now) { // Token is expired (or very close to it) // so let's refresh // Initialize the OAuth client $oauthClient = new \League\OAuth2\Client\Provider\GenericProvider([ 'clientId' => env('OAUTH_APP_ID'), 'clientSecret' => env('OAUTH_APP_PASSWORD'), 'redirectUri' => env('OAUTH_REDIRECT_URI'), 'urlAuthorize' => env('OAUTH_AUTHORITY').env('OAUTH_AUTHORIZE_ENDPOINT'), 'urlAccessToken' => env('OAUTH_AUTHORITY').env('OAUTH_TOKEN_ENDPOINT'), 'urlResourceOwnerDetails' => '', 'scopes' => env('OAUTH_SCOPES') ]); try { $newToken = $oauthClient->getAccessToken('refresh_token', [ 'refresh_token' => session('refreshToken') ]); // Store the new values $this->updateTokens($newToken); return $newToken->getToken(); } catch (League\OAuth2\Client\Provider\Exception\IdentityProviderException $e) { return ''; } } // Token is still valid, just return it return session('accessToken'); }
This method first checks if the access token is expired or close to expiring. If it is, then it uses the refresh token to get new tokens, then updates the cache and returns the new access token.
Get a calendar view
In this exercise you will incorporate the Microsoft Graph into the application. For this application, you will use the microsoft-graph library to make calls to Microsoft Graph.
Get calendar events from Outlook
Create a new directory in the ./app directory named
TimeZones, then create a new file in that directory namedTimeZones.php, and add the following code.<?php // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. namespace App\TimeZones; class TimeZones { public static function getTzFromWindows($windowsTimeZone): \DateTimeZone { $ianaTimeZone = self::$timeZoneMap[$windowsTimeZone]; if (null == $ianaTimeZone) { // Try the value passed in - it is possible // the admins set this to IANA already $ianaTimeZone = $windowsTimeZone; } return new \DateTimeZone($ianaTimeZone); } // Basic lookup for mapping Windows time zone identifiers to // IANA identifiers // Mappings taken from // https://github.com/unicode-org/cldr/blob/master/common/supplemental/windowsZones.xml private static $timeZoneMap = [ "Dateline Standard Time" => "Etc/GMT+12", "UTC-11" => "Etc/GMT+11", "Aleutian Standard Time" => "America/Adak", "Hawaiian Standard Time" => "Pacific/Honolulu", "Marquesas Standard Time" => "Pacific/Marquesas", "Alaskan Standard Time" => "America/Anchorage", "UTC-09" => "Etc/GMT+9", "Pacific Standard Time (Mexico)" => "America/Tijuana", "UTC-08" => "Etc/GMT+8", "Pacific Standard Time" => "America/Los_Angeles", "US Mountain Standard Time" => "America/Phoenix", "Mountain Standard Time (Mexico)" => "America/Chihuahua", "Mountain Standard Time" => "America/Denver", "Central America Standard Time" => "America/Guatemala", "Central Standard Time" => "America/Chicago", "Easter Island Standard Time" => "Pacific/Easter", "Central Standard Time (Mexico)" => "America/Mexico_City", "Canada Central Standard Time" => "America/Regina", "SA Pacific Standard Time" => "America/Bogota", "Eastern Standard Time (Mexico)" => "America/Cancun", "Eastern Standard Time" => "America/New_York", "Haiti Standard Time" => "America/Port-au-Prince", "Cuba Standard Time" => "America/Havana", "US Eastern Standard Time" => "America/Indianapolis", "Turks And Caicos Standard Time" => "America/Grand_Turk", "Paraguay Standard Time" => "America/Asuncion", "Atlantic Standard Time" => "America/Halifax", "Venezuela Standard Time" => "America/Caracas", "Central Brazilian Standard Time" => "America/Cuiaba", "SA Western Standard Time" => "America/La_Paz", "Pacific SA Standard Time" => "America/Santiago", "Newfoundland Standard Time" => "America/St_Johns", "Tocantins Standard Time" => "America/Araguaina", "E. South America Standard Time" => "America/Sao_Paulo", "SA Eastern Standard Time" => "America/Cayenne", "Argentina Standard Time" => "America/Buenos_Aires", "Greenland Standard Time" => "America/Godthab", "Montevideo Standard Time" => "America/Montevideo", "Magallanes Standard Time" => "America/Punta_Arenas", "Saint Pierre Standard Time" => "America/Miquelon", "Bahia Standard Time" => "America/Bahia", "UTC-02" => "Etc/GMT+2", "Azores Standard Time" => "Atlantic/Azores", "Cape Verde Standard Time" => "Atlantic/Cape_Verde", "UTC" => "Etc/GMT", "GMT Standard Time" => "Europe/London", "Greenwich Standard Time" => "Atlantic/Reykjavik", "Sao Tome Standard Time" => "Africa/Sao_Tome", "Morocco Standard Time" => "Africa/Casablanca", "W. Europe Standard Time" => "Europe/Berlin", "Central Europe Standard Time" => "Europe/Budapest", "Romance Standard Time" => "Europe/Paris", "Central European Standard Time" => "Europe/Warsaw", "W. Central Africa Standard Time" => "Africa/Lagos", "Jordan Standard Time" => "Asia/Amman", "GTB Standard Time" => "Europe/Bucharest", "Middle East Standard Time" => "Asia/Beirut", "Egypt Standard Time" => "Africa/Cairo", "E. Europe Standard Time" => "Europe/Chisinau", "Syria Standard Time" => "Asia/Damascus", "West Bank Standard Time" => "Asia/Hebron", "South Africa Standard Time" => "Africa/Johannesburg", "FLE Standard Time" => "Europe/Kiev", "Israel Standard Time" => "Asia/Jerusalem", "Kaliningrad Standard Time" => "Europe/Kaliningrad", "Sudan Standard Time" => "Africa/Khartoum", "Libya Standard Time" => "Africa/Tripoli", "Namibia Standard Time" => "Africa/Windhoek", "Arabic Standard Time" => "Asia/Baghdad", "Turkey Standard Time" => "Europe/Istanbul", "Arab Standard Time" => "Asia/Riyadh", "Belarus Standard Time" => "Europe/Minsk", "Russian Standard Time" => "Europe/Moscow", "E. Africa Standard Time" => "Africa/Nairobi", "Iran Standard Time" => "Asia/Tehran", "Arabian Standard Time" => "Asia/Dubai", "Astrakhan Standard Time" => "Europe/Astrakhan", "Azerbaijan Standard Time" => "Asia/Baku", "Russia Time Zone 3" => "Europe/Samara", "Mauritius Standard Time" => "Indian/Mauritius", "Saratov Standard Time" => "Europe/Saratov", "Georgian Standard Time" => "Asia/Tbilisi", "Volgograd Standard Time" => "Europe/Volgograd", "Caucasus Standard Time" => "Asia/Yerevan", "Afghanistan Standard Time" => "Asia/Kabul", "West Asia Standard Time" => "Asia/Tashkent", "Ekaterinburg Standard Time" => "Asia/Yekaterinburg", "Pakistan Standard Time" => "Asia/Karachi", "Qyzylorda Standard Time" => "Asia/Qyzylorda", "India Standard Time" => "Asia/Calcutta", "Sri Lanka Standard Time" => "Asia/Colombo", "Nepal Standard Time" => "Asia/Katmandu", "Central Asia Standard Time" => "Asia/Almaty", "Bangladesh Standard Time" => "Asia/Dhaka", "Omsk Standard Time" => "Asia/Omsk", "Myanmar Standard Time" => "Asia/Rangoon", "SE Asia Standard Time" => "Asia/Bangkok", "Altai Standard Time" => "Asia/Barnaul", "W. Mongolia Standard Time" => "Asia/Hovd", "North Asia Standard Time" => "Asia/Krasnoyarsk", "N. Central Asia Standard Time" => "Asia/Novosibirsk", "Tomsk Standard Time" => "Asia/Tomsk", "China Standard Time" => "Asia/Shanghai", "North Asia East Standard Time" => "Asia/Irkutsk", "Singapore Standard Time" => "Asia/Singapore", "W. Australia Standard Time" => "Australia/Perth", "Taipei Standard Time" => "Asia/Taipei", "Ulaanbaatar Standard Time" => "Asia/Ulaanbaatar", "Aus Central W. Standard Time" => "Australia/Eucla", "Transbaikal Standard Time" => "Asia/Chita", "Tokyo Standard Time" => "Asia/Tokyo", "North Korea Standard Time" => "Asia/Pyongyang", "Korea Standard Time" => "Asia/Seoul", "Yakutsk Standard Time" => "Asia/Yakutsk", "Cen. Australia Standard Time" => "Australia/Adelaide", "AUS Central Standard Time" => "Australia/Darwin", "E. Australia Standard Time" => "Australia/Brisbane", "AUS Eastern Standard Time" => "Australia/Sydney", "West Pacific Standard Time" => "Pacific/Port_Moresby", "Tasmania Standard Time" => "Australia/Hobart", "Vladivostok Standard Time" => "Asia/Vladivostok", "Lord Howe Standard Time" => "Australia/Lord_Howe", "Bougainville Standard Time" => "Pacific/Bougainville", "Russia Time Zone 10" => "Asia/Srednekolymsk", "Magadan Standard Time" => "Asia/Magadan", "Norfolk Standard Time" => "Pacific/Norfolk", "Sakhalin Standard Time" => "Asia/Sakhalin", "Central Pacific Standard Time" => "Pacific/Guadalcanal", "Russia Time Zone 11" => "Asia/Kamchatka", "New Zealand Standard Time" => "Pacific/Auckland", "UTC+12" => "Etc/GMT-12", "Fiji Standard Time" => "Pacific/Fiji", "Chatham Islands Standard Time" => "Pacific/Chatham", "UTC+13" => "Etc/GMT-13", "Tonga Standard Time" => "Pacific/Tongatapu", "Samoa Standard Time" => "Pacific/Apia", "Line Islands Standard Time" => "Pacific/Kiritimati" ]; } // </zoneMappingsSnippet>This class implements a simplistic mapping of Windows time zone names to IANA time zone identifiers.
Create a new file in the ./app/Http/Controllers directory named
CalendarController.php, and add the following code.<?php namespace App\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\Request; use Microsoft\Graph\Graph; use Microsoft\Graph\Model; use App\TokenStore\TokenCache; use App\TimeZones\TimeZones; class CalendarController extends Controller { public function calendar() { $viewData = $this->loadViewData(); $graph = $this->getGraph(); // Get user's timezone $timezone = TimeZones::getTzFromWindows($viewData['userTimeZone']); // Get start and end of week $startOfWeek = new \DateTimeImmutable('sunday -1 week', $timezone); $endOfWeek = new \DateTimeImmutable('sunday', $timezone); $viewData['dateRange'] = $startOfWeek->format('M j, Y').' - '.$endOfWeek->format('M j, Y'); $queryParams = array( 'startDateTime' => $startOfWeek->format(\DateTimeInterface::ISO8601), 'endDateTime' => $endOfWeek->format(\DateTimeInterface::ISO8601), // Only request the properties used by the app '$select' => 'subject,organizer,start,end', // Sort them by start time '$orderby' => 'start/dateTime', // Limit results to 25 '$top' => 25 ); // Append query parameters to the '/me/calendarView' url $getEventsUrl = '/me/calendarView?'.http_build_query($queryParams); $events = $graph->createRequest('GET', $getEventsUrl) // Add the user's timezone to the Prefer header ->addHeaders(array( 'Prefer' => 'outlook.timezone="'.$viewData['userTimeZone'].'"' )) ->setReturnType(Model\Event::class) ->execute(); return response()->json($events); } private function getGraph(): Graph { // Get the access token from the cache $tokenCache = new TokenCache(); $accessToken = $tokenCache->getAccessToken(); // Create a Graph client $graph = new Graph(); $graph->setAccessToken($accessToken); return $graph; } }Consider what this code is doing.
- The URL that will be called is
/v1.0/me/calendarView. - The
startDateTimeandendDateTimeparameters define the start and end of the view. - The
$selectparameter limits the fields returned for each events to just those the view will actually use. - The
$orderbyparameter sorts the results by the date and time they were created, with the most recent item being first. - The
$topparameter limits the results to 25 events. - The
Prefer: outlook.timezone=""header causes the start and end times in the response to be adjusted to the user's preferred time zone.
- The URL that will be called is
Update the routes in ./routes/web.php to add a route to this new controller.
Route::get('/calendar', 'CalendarController@calendar');Sign in and click the Calendar link in the nav bar. If everything works, you should see a JSON dump of events on the user's calendar.
Display the results
Now you can add a view to display the results in a more user-friendly manner.
Create a new file in the ./resources/views directory named
calendar.blade.phpand add the following code.@extends('layout') @section('content') <h1>Calendar</h1> <h2>{{ $dateRange }}</h2> <a class="btn btn-light btn-sm mb-3" href={{action('CalendarController@getNewEventForm')}}>New event</a> <table class="table"> <thead> <tr> <th scope="col">Organizer</th> <th scope="col">Subject</th> <th scope="col">Start</th> <th scope="col">End</th> </tr> </thead> <tbody> @isset($events) @foreach($events as $event) <tr> <td>{{ $event->getOrganizer()->getEmailAddress()->getName() }}</td> <td>{{ $event->getSubject() }}</td> <td>{{ \Carbon\Carbon::parse($event->getStart()->getDateTime())->format('n/j/y g:i A') }}</td> <td>{{ \Carbon\Carbon::parse($event->getEnd()->getDateTime())->format('n/j/y g:i A') }}</td> </tr> @endforeach @endif </tbody> </table> @endsectionThat will loop through a collection of events and add a table row for each one.
Update the routes in ./routes/web.php to add routes for
/calendar/new. You will implement these functions in the next section, but the route need to be defined now because calendar.blade.php references it.Route::get('/calendar/new', 'CalendarController@getNewEventForm'); Route::post('/calendar/new', 'CalendarController@createNewEvent');Remove the
return response()->json($events);line from thecalendaraction in ./app/Http/Controllers/CalendarController.php, and replace it with the following code.$viewData['events'] = $events; return view('calendar', $viewData);Refresh the page and the app should now render a table of events.

Create a new event
In this section you will add the ability to create events on the user's calendar.
Create new event form
Create a new file in the ./resources/views directory named
newevent.blade.phpand add the following code.@extends('layout') @section('content') <h1>New event</h1> <form method="POST"> @csrf <div class="form-group"> <label>Subject</label> <input type="text" class="form-control" name="eventSubject" /> </div> <div class="form-group"> <label>Attendees</label> <input type="text" class="form-control" name="eventAttendees" /> </div> <div class="form-row"> <div class="col"> <div class="form-group"> <label>Start</label> <input type="datetime-local" class="form-control" name="eventStart" id="eventStart" /> </div> @error('eventStart') <div class="alert alert-danger">{{ $message }}</div> @enderror </div> <div class="col"> <div class="form-group"> <label>End</label> <input type="datetime-local" class="form-control" name="eventEnd" /> </div> @error('eventEnd') <div class="alert alert-danger">{{ $message }}</div> @enderror </div> </div> <div class="form-group"> <label>Body</label> <textarea type="text" class="form-control" name="eventBody" rows="3"></textarea> </div> <input type="submit" class="btn btn-primary mr-2" value="Create" /> <a class="btn btn-secondary" href={{ action('CalendarController@calendar') }}>Cancel</a> </form> @endsection
Add controller actions
Open ./app/Http/Controllers/CalendarController.php and add the following function to render the form.
public function getNewEventForm() { return view('newevent'); }Add the following function to receive the form data when the user's submits, and create a new event on the user's calendar.
public function createNewEvent(Request $request) { // Validate required fields $request->validate([ 'eventSubject' => 'nullable|string', 'eventAttendees' => 'nullable|string', 'eventStart' => 'required|date', 'eventEnd' => 'required|date', 'eventBody' => 'nullable|string' ]); $viewData = $this->loadViewData(); $graph = $this->getGraph(); // Attendees from form are a semi-colon delimited list of // email addresses $attendeeAddresses = explode(';', $request->eventAttendees); // The Attendee object in Graph is complex, so build the structure $attendees = []; foreach($attendeeAddresses as $attendeeAddress) { array_push($attendees, [ // Add the email address in the emailAddress property 'emailAddress' => [ 'address' => $attendeeAddress ], // Set the attendee type to required 'type' => 'required' ]); } // Build the event $newEvent = [ 'subject' => $request->eventSubject, 'attendees' => $attendees, 'start' => [ 'dateTime' => $request->eventStart, 'timeZone' => $viewData['userTimeZone'] ], 'end' => [ 'dateTime' => $request->eventEnd, 'timeZone' => $viewData['userTimeZone'] ], 'body' => [ 'content' => $request->eventBody, 'contentType' => 'text' ] ]; // POST /me/events $response = $graph->createRequest('POST', '/me/events') ->attachBody($newEvent) ->setReturnType(Model\Event::class) ->execute(); return redirect('/calendar'); }Consider what this code does.
Save all of your changes and restart the server. Use the New event button to navigate to the new event form.
Fill in the values on the form. Use a start date from the current week. Select Create.

When the app redirects to the calendar view, verify that your new event is present in the results.
Congratulations!
You've completed the PHP Microsoft Graph tutorial. Now that you have a working app that calls Microsoft Graph, you can experiment and add new features. Visit the Overview of Microsoft Graph to see all of the data you can access with Microsoft Graph.
Feedback
Please provide any feedback on this tutorial in the GitHub repository.
Have an issue with this section? If so, please give us some feedback so we can improve this section.