Fun with the Gitlab API and PHP

IPC Munich 2025

Frank Berger

A bit about me

  • Frank Berger
  • Head of Engineering at sudhaus7.de, a label of the B-Factor GmbH, member of the code711.de network
  • Started as an Unix Systemadministrator who also develops in 1996
  • Working with PHP since V3
  • Does TYPO3 since 2005

About the API

Gitlab offers a REST API

Almost all tasks can be done through the API

Without using a git binary

https://docs.gitlab.com/api/rest/

PHP Clients

m4tthumphrey/php-gitlab-api (mentioned by Gitlab itself)

Verb-complete implementation

Pure userland implementation without the need of a git binary or extension

Creating a personal access token

A personal access token will inherit the user level of the user on the respecting project!

Creating a project access token

With a project level access token, the project-id or project-path must be provided for almost all API-actions

What to define in the token

Other Tokens

OAUTH2 Token

An OAUTH2 Token can be used as well, with Gitlab as an IdP, but that is probably not practical for automated processes

CI_JOB_TOKEN

When running a tool inside a CI/CD queue, the CI_JOB_TOKEN can be used to access the Gitlab API

Connect to the Gitlab Server


$client = new Gitlab\Client();
$client->setUrl(getenv('GITLAB_SERVER')); // https://gitlab.com/
$client->authenticate(
    getenv('GITLAB_ACCESS_TOKEN'), // glpat-XXXXX-XXX....
    Gitlab\Client::AUTH_HTTP_TOKEN
);
                    

Any PSR-18 HTTP-Client can be injected as well

Let's try to list some projects


$projects = $client
    ->projects()
    ->all();

foreach($projects as $project) {
    printf("%d %s (%s)\n",
        $project['id'],
        $project['name_with_namespace'],
        $project['path_with_namespace']
    );
}
                    

That's a lot of projects... Filters?


$projects = $client
    ->projects()
    ->all(['membership'=>true]);

foreach($projects as $project) {
    printf("%d %s (%s)\n",
        $project['id'],
        $project['name_with_namespace'],
        $project['path_with_namespace']
    );
}
                    

Reminder: a user-level access token will give access to everything the user can see

Contents of $project


{
  "id": 75547854,
  "description": null,
  "name": "Demo Talk",
  "name_with_namespace": "Frank Berger / Demo Talk",
  "path": "demo-talk",
  "path_with_namespace": "foppelfb/demo-talk",
  "created_at": "2025-10-23T14:06:26.665Z",
  "default_branch": "main",
  "tag_list": [],
  "topics": [],
  "ssh_url_to_repo": "git@gitlab.com:foppelfb/demo-talk.git",
  "http_url_to_repo": "https://gitlab.com/foppelfb/demo-talk.git",
  "web_url": "https://gitlab.com/foppelfb/demo-talk",
  "readme_url": "https://gitlab.com/foppelfb/demo-talk/-/blob/main/README.md",
  "forks_count": 0,
  "avatar_url": null,
  "star_count": 0,
  "last_activity_at": "2025-10-23T14:06:26.572Z",
  "visibility": "private",
  "namespace": {
    "id": 4658152,
    "name": "Frank Berger",
    "path": "foppelfb",
    "kind": "user",
    "full_path": "foppelfb",
    "parent_id": null,
    "avatar_url": "https://secure.gravatar.com/avatar/c9f3b5a9949724de5da93fcf1ea189dd31131a25502fb8555eb7cff3fab5cfad?s=80&d=identicon",
    "web_url": "https://gitlab.com/foppelfb"
  },
  "container_registry_image_prefix": "registry.gitlab.com/foppelfb/demo-talk",
  "_links": {
    "self": "https://gitlab.com/api/v4/projects/75547854",
    "issues": "https://gitlab.com/api/v4/projects/75547854/issues",
    "merge_requests": "https://gitlab.com/api/v4/projects/75547854/merge_requests",
    "repo_branches": "https://gitlab.com/api/v4/projects/75547854/repository/branches",
    "labels": "https://gitlab.com/api/v4/projects/75547854/labels",
    "events": "https://gitlab.com/api/v4/projects/75547854/events",
    "members": "https://gitlab.com/api/v4/projects/75547854/members",
    "cluster_agents": "https://gitlab.com/api/v4/projects/75547854/cluster_agents"
  },
  "marked_for_deletion_at": null,
  "marked_for_deletion_on": null,
  "packages_enabled": true,
  "empty_repo": false,
  "archived": false,
  "owner": {
    "id": 3583153,
    "username": "foppelfb",
    "public_email": "",
    "name": "Frank Berger",
    "state": "active",
    "locked": false,
    "avatar_url": "https://secure.gravatar.com/avatar/c9f3b5a9949724de5da93fcf1ea189dd31131a25502fb8555eb7cff3fab5cfad?s=80&d=identicon",
    "web_url": "https://gitlab.com/foppelfb"
  },
  "resolve_outdated_diff_discussions": false,
  "container_expiration_policy": {
    "cadence": "1d",
    "enabled": false,
    "keep_n": 10,
    "older_than": "90d",
    "name_regex": ".*",
    "name_regex_keep": null,
    "next_run_at": "2025-10-24T14:06:26.695Z"
  },
  "repository_object_format": "sha1",
  "issues_enabled": true,
  "merge_requests_enabled": true,
  "wiki_enabled": true,
  "jobs_enabled": true,
  "snippets_enabled": true,
  "container_registry_enabled": true,
  "service_desk_enabled": true,
  "service_desk_address": "contact-project+foppelfb-demo-talk-75547854-issue-@incoming.gitlab.com",
  "can_create_merge_request_in": true,
  "issues_access_level": "enabled",
  "repository_access_level": "enabled",
  "merge_requests_access_level": "enabled",
  "forking_access_level": "enabled",
  "wiki_access_level": "enabled",
  "builds_access_level": "enabled",
  "snippets_access_level": "enabled",
  "pages_access_level": "private",
  "analytics_access_level": "enabled",
  "container_registry_access_level": "enabled",
  "security_and_compliance_access_level": "private",
  "releases_access_level": "enabled",
  "environments_access_level": "enabled",
  "feature_flags_access_level": "enabled",
  "infrastructure_access_level": "enabled",
  "monitor_access_level": "enabled",
  "model_experiments_access_level": "enabled",
  "model_registry_access_level": "enabled",
  "package_registry_access_level": "enabled",
  "emails_disabled": false,
  "emails_enabled": true,
  "show_diff_preview_in_email": true,
  "shared_runners_enabled": true,
  "lfs_enabled": true,
  "creator_id": 3583153,
  "import_status": "none",
  "open_issues_count": 1,
  "description_html": "",
  "updated_at": "2025-10-23T14:06:28.259Z",
  "ci_config_path": "",
  "public_jobs": true,
  "shared_with_groups": [],
  "only_allow_merge_if_pipeline_succeeds": false,
  "allow_merge_on_skipped_pipeline": null,
  "request_access_enabled": true,
  "only_allow_merge_if_all_discussions_are_resolved": false,
  "remove_source_branch_after_merge": true,
  "printing_merge_request_link_enabled": true,
  "merge_method": "merge",
  "merge_request_title_regex": null,
  "merge_request_title_regex_description": null,
  "squash_option": "default_off",
  "enforce_auth_checks_on_uploads": true,
  "suggestion_commit_message": null,
  "merge_commit_template": null,
  "squash_commit_template": null,
  "issue_branch_template": null,
  "warn_about_potentially_unwanted_characters": true,
  "autoclose_referenced_issues": true,
  "max_artifacts_size": null,
  "external_authorization_classification_label": "",
  "requirements_enabled": false,
  "requirements_access_level": "enabled",
  "security_and_compliance_enabled": true,
  "compliance_frameworks": [],
  "duo_remote_flows_enabled": true,
  "permissions": {
    "project_access": {
      "access_level": 30,
      "notification_level": 3
    },
    "group_access": null
  }
}
                     

What about issues?


$issues = $client
    ->issues()
    ->all('foppelfb/demo-talk'); // id or path

foreach($issues as $issue) {
	printf("[%d] ID %d in %d: %s (%s)\n%s\n",
		$issue['id'],
		$issue['iid'],
		$issue['project_id'],
		$issue['title'],
		$issue['author']['name'],
		$issue['web_url']
	);
}
                    

There is a Pager Strategy available

Idea: creating an issue from the backend/feedback form, or from inside pipelines, or ...


$issue = $client
	->issues()
	->create('foppelfb/demo-talk',[
	'title'=>$title,
	'description'=>$body,
]);
printf("[%d] ID %d in %s: %s (%s)\n%s\n",
	$issue['id'],
	$issue['iid'],
	$issue['project_id'],
	$issue['title'],
	$issue['author']['name'],
	$issue['web_url']
);
                    

Tip: use different access tokens for different tasks

Available verbs/actions


$client->projects();
$client->issues();
$client->version();
$client->deployKeys(); // readonly
$client->environments();
$client->deployments();
$client->jobs();
$client->users();
$client->groups();
$client->tags();
$client->wiki();

$client->repositories();
$client->repositoryFiles();
                    

and a few more

Well, that is nice... but not really beyond normal operations / management

Listing branches


$branches = $client
    ->repositories()
    ->branches('foppelfb/demo-talk');
foreach($branches as $branch) {
    printf("%s : Last commit %s (%s)\n%s: %s\n\n",
        $branch['name'],
        $branch['commit']['short_id'],
        $branch['commit']['created_at'],
        $branch['commit']['author_name'],
        $branch['commit']['title']
    );
}
                    

$branch['commit'] == HEAD

Creating a branch


try {
  $branch = $client->repositories()
  ->createBranch('foppelfb/demo-talk',$newBranch, 'main');

  printf("%s : Last commit %s (%s)\n%s: %s\n\n",
    $branch['name'], $branch['commit']['short_id'],
    $branch['commit']['created_at'],
    $branch['commit']['author_name'],
    $branch['commit']['title']
  );

} catch(\Gitlab\Exception\ValidationFailedException $e) {
	echo $e->getMessage(),"\n";
}
                    

Getting the content of a file


$file = 'my/path/file.txt';
try {
    $file = $client->repositoryFiles()
        ->getFile('foppelfb/demo-talk', $file, 'main');
} catch (\Gitlab\Exception\RuntimeException $e) {
    // $e->getCode() == 404
    $file = null;
}
try {
    $raw = $client->repositoryFiles()
        ->getRawFile('foppelfb/demo-talk', $file, 'main');
} catch (\Gitlab\Exception\RuntimeException $e) {
    $raw = null;
}
                    

this is the only (atomic) way to check if a file exists

branch or reference?


                        $client->repositoryFiles()
                            ->getFile('foppelfb/demo-talk', $file, ref: 'main');
                    

'main' is a reference, can be a commit hash as well as a branch

Creating or updating a file


$payload = [
	'file_path' => $file, // path/to/my/file.txt
	'branch' => $branch,
	'content' => $content, // plain text or base64
	'commit_message' => $message,
	'author_email' => 'fberger@sudhaus7.de',
	'author_name' => 'Its a me, Franky',
];
if ($raw !== null) { // update
	$result = $client->repositoryFiles()
        ->updateFile('foppelfb/demo-talk', $payload);
} else { //create
	$result = $client->repositoryFiles()
        ->createFile('foppelfb/demo-talk', $payload);
}
                    

Available actions for
$client->repositoryFiles()

  • ->getFile()
  • ->getRawFile()
  • ->createFile()
  • ->updateFile()
  • ->deleteFile()

All writing methods create singular commits in the repository

Moving or renaming a file


$commit = $client->repositories()
    ->createCommit( 'foppelfb/demo-talk', parameters: [
        'branch'=>$branch,
        'commit_message'=>$commitmessage,
        'actions'=>[
            [
                'action'=>'move',
                'previous_path'=>$oldfilename,
                'file_path'=> $newfilename,
            ]
        ],
        'author_email'=>'fberger@sudhaus7.de',
        'author_name'=>'The other Franky',
] );
                    

Possible actions are: create, delete, move, update, chmod

You can add several actions and create a branch in one go

Creating a merge request


$parameters = [
    'remove_source_branch'=>true,
    'squash'=>true,
    'assignee_id'=>123456,
];
try {
    $mr = $client->mergeRequests()->create(
        'foppelfb/demo-talk',
        $srcBranch,
        $tgtBranch,
        $message,
        $parameters
    );
} catch (\Gitlab\Exception\RuntimeException $e) {
    // $e->getCode() == 409 -> merge request exists
    echo $e->getCode(),' ',$e->getMessage(),"\n";
}
                    

check $mr['merge_status'] and $mr['detailed_merge_status'] if you want to merge

Finally: merging a mergerequest


$mr = $client->mergeRequests()
  ->show( 'foppelfb/demo-talk', $iid);
if (
    $mr['detailed_merge_status'] === 'mergeable' &&
    (int)$mr['user']['can_merge']===1
) {
    $mrResult = $client->mergeRequests()
        ->merge('foppelfb/demo-talk', $iid, []);
    printf("%s\n",$mrResult['merged_at']);
} else {
    printf("ERROR: %s: %s - user can merge: %d\n",
        $mr['merge_status'], $mr['detailed_merge_status'],
        $mr['user']['can_merge']
    );
}
                    

rebase is available as well, of course

Examples where this could be used

  • Applications that can write files which exist in the repository as well
  • Ticket generation from inside the Application
  • Self-healing CI/CD Pipelines
  • Reporting / Ticketing in the same space
  • Automatic documentation in Wiki
  • Automated upgrade and maintenance tasks

Something for TYPO3

code711/siteconfiggitsync | EXT:siteconfiggitsync

An extension for TYPO3 that allows to keep the configuration for sites and site-sets (YAML Config files) - which can be edited through the backend by admins/editors - managed in GIT

Has support for GitHub as well

Keeping ticket systems "in sync"

Customer facing we have a Redmine system

Developers use code-related tickets in Gitlab

Tools keep the ticket relations and release management in sync

What are your questions?

Thank you, I am here all weekend

Twitter: @FoppelFB | Mastodon: @foppel@phpc.social

fberger@sudhaus7.de | https://sudhaus7.de/