This document describes the big picture of the structure of the ILIAS source code. On the one hand, the code is organized on the file system and in PHP namespaces. On the other hand, the various components of ILIAS depend on each other in certain ways and hence have a logical structure. Both aspects of structure are described here. The document targets all ILIAS developers, as they all will work with and in the presented structures.
The document was created to further the goals of the Component Revision 2022. Hence, as of the creation of this document, the presented structure is not completely implemented and instead a goal is described, towards which the community should work.
Every section starts with a general topology of the problem at hand. It proceeds with directions how the problem is solved in ILIAS and finishes with in-depth explanations on why certain things should be as described.
This part of the document describes the layout of the filesystem and the PHP-Namespaces of the ILIAS code and the components that form the system. The dependencies between the components are described in the chapter Types of Dependencies and Integration Strategies.
This describes how the root level of the ILIAS repository and an installation of ILIAS will look like. Many parts of a productive ILIAS installation are automatically created via a build process, hence an installation will contain more files than the ILIAS repository. Still, the structures of both should match to allow developers to quickly find their way around and admins to update installations via git.
The root of the ILIAS repository contains the following directories and files:
.github
contains configuration for GitHub-integrations, such as workflows. Executables for this purpose are located in scripts.cli
contains executables for productive ILIAS systems that are to be run on the command line, such as the setup.php and cron.php.docs
contains documentation that is relevant for the complete system. Individual components may havedocs
-folders as well that contain information only about that component.lang
: contains language file as iscomponents
contains the code for ILIASscripts
: contains scripts to be used during development and for CItemplates
: contains style code and templates as is
On productive installations and for release packages, these folders will be amended by some folders generated by a build-process:
artifacts
: will contain generated artifacts from ILIASCustomizing
: will contain language-modifications and additional skins, but no plugins anymore.node_modules
: contains JavaScript-libraries as isvendor
: contains PHP-libraries as ispublic
: Contains files and folders that need to be exposed to the web, so this folder should be linked to www/html
Configuration and scripts are splitted between .github
and the scripts
so same scripts can be used by developers from the command line or integrated into
their IDEs. In general, this separation of concerns fosters a style of dev-scripts
that allows for custom automations at individual organisations that use ILIAS and
simplifies switching away from GitHub to another software forge.
cli
and scripts
are different folders because the further will be needed
on production systems while the latter should be removed on them as it is only used
for development purpose.
docs
may also exist in individual components but then only has the scope of
that component. On the one hand, we need a systemwide entrypoint for documentation
to look into high level concepts, processes and provide direction to other resources.
On the other hand, documentation about certain components should be close to the
component in question,
lang
, template
and Customizing
stay as is for the time being, as
the system can only support a certain amount of change per timeframe. In the long
run it seems advisable to move these folders as well. lang
might be a candidate
to be split up into single components as well. templates
might be moved
completely into the UI-framework once legacy UI components are removed completely.
Customizing
will be a candidate for future treatment once lang
and template
are reconsidered.
public
is introduced so installations can stop exposing each and every file
in the ILIAS repository. This is bad for security and reliability, as the ILILAS
repository contains loads of files that should not be accessible via web: files
containing automated tests, build artifacts, documentation.
The components
folder is the central folder for the ILIAS development. It contains
components that are maintained and developed via the processes of the ILIAS society
and community. It may also contain components developed by third parties, formerly
known as plugins. Hence, this folder is an implementation of one central aim of
the Component Revision 2022:
Mechanisms used by core components and plugins should be aligned.
The top-level in the components
starts with a list of all vendors that provide
components for the installation at hand. Hence, for a standard ILIAS, only one
folder ILIAS
will be contained. When the installation uses plugins, the vendors
of the plugin will have folders as well.
The folders of the individual vendors in turn contain one folder for each component provided by the vendor in question. Hence a component folder might look as such:
- ILIAS
- AccessControl
- Administration
- Blog
- Course
- ...
- ServiceProvider1
- Plugin1
- Plugin2
- ServiceProvider2
- Plugin1
Each of these folder contains an according component. The \src
directory
in that folder contains the root of an according namespace, as described
by PSR-4. For example: components/ILIAS/AccessControl
contains the component ILIAS\AccessControl
, components/ILIAS/AccessControl/src
is the root of the namespace ILIAS\AccessControl
.
A components folder also contains this substructure:
/docs
for component specific documentation/src
for the code to be used for production/tests
for code that performs automated tests/resources
for auxiliary files like icons, endpoints- a
$COMPONENT.php
(e.g. AccessControl.php) that defines the binding of the component to the rest of the system - a
component.json
that contains component metadata, such as contact of maintainers - a
README.md
that gives human readable info about the component
PHP-Namespacing with its Vendor\Package
logic is a good fit for ILIAS components.
It provides a natural way to resolve naming clashes between all component and
plugins and fits well in the general PHP ecosystem.
Although for a standard ILIAS installation the additional ILIAS
folder in components
might seem unnecessary, it provides a natural way to add plugins from other vendors
and should make it easier for people finding a specific component. If for some reason
plugins should be backed up or in any other way be treated differently than ILIAS
components, this could still be implemented via a simple directory based approach,
as was also possible with the Customizing
folder.
The superficial distinction between "Modules" and "Services" is dropped, as it doesn't
seem to be helpful anymore. On further iterations on the integration mechanism used
in ILIAS, it seems to be desirable that the idea, that one component (previously:
"Module") may only provide one type of repository object is dropped. This will clarify
the directory layout for components that in fact provide several repository objects,
such as Course
and CourseReference
.
The previous XML-based component definition is split into two distinct files.
component.json
contains metadata about the component, such as names of
maintainers, version numbers and contacts. $COMPONENT.php
defines, how the
component is integrated with other components of the system, e.g. the CronJobs
or the Event System.
The xml-based definition was droppped for various reasons:
- A custom XML format requires its own parser that needs to be maintained. The parser, in turn, needs custom mechanisms to be extended by other components.
- XML and PHP are equally expressive, but PHP-IDEs provide helpers like syntax highlighting or code completion for PHP and not for custom XML.
- The mechanism and approaches for writing and maintaining PHP code are well established in the ILIAS community. This is not true for XML.
This chapter describes how different components of the system may depend on each
other and how these dependencies are implemented. Components may implement different
patterns in their relation to other components. A component might define one service
and provide code to ease the implementation of that service, but
also pull code from yet another component. The component ILIAS\AccessControl
,
for example, defines how an ilAccessHandler
(yet to be namespaced...) needs to
look and also provides a base class ilAccess
(yet to be namespaced...) to ease
the implementation of the handler. It also pulls code from ILIAS\Refinery
to
implement some checks.
Each of the types of dependencies described here might in general be implemented by various means from the perspective of PHP. We still strive to limit ourselves to certain patterns for the different types. Some patterns lead to better overall properties of the system then others, a limited number of patterns make the system comprehensible.
The types of dependencies and the patterns have a different strength of coupling. In general, we try to strive to use weaker forms of dependencies if possible, to reduce coupling between components of the system.
Location and namespacing of components is discussed in the according section. What's left is a description how components actually integrate into the rest of the system and how the according code looks like. This section describes the code that needs to be put into the component to actually define the integration.
The integrations are defined by implementing the interface \ILIAS\Core\Component
for the class that has the same qualified name as the component it ought to describe.
The component Vendor\SomeComponent
should have file SomeComponent.php
in its
folder, containing a class Vendor\SomeComponent
. The interface \ILIAS\Core\Component
defines one method init
, that describes the integration with other components:
namespace Vendor;
class SomeComponent implements Component
{
public function init(
array | \ArrayAccess &$define,
array | \ArrayAccess &$implement,
array | \ArrayAccess &$use,
array | \ArrayAccess &$seek,
array | \ArrayAccess &$contribute,
array | \ArrayAccess &$provide,
array | \ArrayAccess &$pull,
array | \ArrayAccess &$internal,
) : void {
// ...
}
}
The integration uses a dependency injection container, as established in Services\Init
over the last years. Instead of one container we use eight containers for the various
types of integrations described below and for internal use.
The fields in the containers need to be filled with closures to allow for defered instantion of the objects we define. All containers that allow to access objects can be used during instantiation. The easiest way to fill the containers hence is to use anonymouns short closures which can access variables from the complete scope.
$internal["my_internal_thingy"] = fn() => new InternalThingy($use[SomeOtherService::class]);
The different containers represents different directions of dependencies, hence some of them can be only written, some of them can only be read:
$define
,$implement
,$contribute
and$provide
define facilities that the component at hand offers to the rest of the system. They can only be written.$use
,$seek
,$pull
define requirements that the component demands from the rest of the system. They can only be read.
The $internal
is meant to construct interal wiring of the components, hence it can
be read and written. It can be used for facilities that are internal to the component,
but it can also be used to wire up external dependencies. A component might for
example provide some facility that contributes to various other components.
$internal
can then be used to contribute that facility to both other components:
$internal["my_internal_thingy"] = fn() => new InternalThingy();
$contribute[Vendor\Component\SomeContribution::class] = fn () => $internal["my_internal_thingy"];
$contribute[Vendor\OtherComponent\OtherContribution::class] = fn () => $internal["my_internal_thingy"];
The technique to define dependency graphs via array-access syntax and anonymous functions is well established by the pimple dependency injection container. There are tons of other dependency injection containers out there, many of them using other techniques to define the dependency graph. For our use case we consider the pimple technique to be appropriate:
- There is no need to learn a domain specific language for the dependency graph, as e.g. the dependency injection component of symfony uses.
- There is no need to learn a new PHP interface to define the dependency graph, as e.g. the dependency injection component of symfony uses.
- The environment is easy to set up, because all we need for a first approximation are arrays. Hence the technique is good for automated testing and general tinkering. No need to parse any file or mock some interface.
We propose to increase the amount of containers that are used to define the graph to eight since we are looking to get a good grasp of the dependencies in the system. There are fundamentally different types of dependencies, as outlined below, that we need to model and want to understand. One array-like object would not be enough to capture these types. We are then faced with the decision to make one container more complex (see above) or just use more containers (as proposed).
A component may define the interface to a service it provides. To be a service definition it is crucial that the interface...
- ...is defined from the perspective of an intended usage, not an intended or actual implementation
- ...is minimal, i.e. only contains what is necessary for the intended usage
- ...is an abstraction, i.e. hides complexity, instead of being a generalisation which spans complexity
- ...is the frontend to some subsystem, not just a way to hide some implementation details
A mostly good example for the service definition is the Logger
-interface of PSR-3.
It is clearly defined from a usage-perspective and it hides various potentially
very different or complex systems or implementations behind a fairly abstract
interface. An anti-example from the same domain is the ilLog
-class that publishes
various implementation details of a concrete implementation of a certain logging
mechanism.
A service definition and the interface to the service do not necessarily need to be made from one PHP interface or class only. Dependending on the subsystem and intended usage that is abstracted by the service definition, various interfaces or even classes for data transfer might be required.
Still, if a service definition sticks to the qualities defined above, it should be simple to reimplement it, outsource it to some other system, provide an alternative implementation, or, in general, just replace it by another implementation. Which is a sign for decoupled components and hence a flexible system.
This is the weakest form of coupling, as the component only provides some definition to the world without knowing other components.
To define a service, the component needs to specify the service via some interface:
namespace Vendor\Component;
interface Service
{
/* ... */
}
To announce that the service exists the component puts the interface in the container
named $define
:
namespace Vendor;
use Vendor\Component\Service;
class Component implements \ILIAS\Core\Component
{
public function init(
array | \ArrayAccess &$define,
// ...
) {
$define[Vendor\Component\Service::class] = null;
}
This just announces the existence of the service to the system. The component might also provide a Null-object or a minimal implementation for the service:
namespace Vendor;
use Vendor\Component\Service;
class Component implements \ILIAS\Core\Component
{
public function init(
array | \ArrayAccess &$define,
// ...
) {
$define[Vendor\Component\Service::class] = fn () => new NullService();
}
The Null-object or minimal implementation may not depend on anything else and need to have an empty parameter list in its constructor.
Before this proposal there is no unified way to speak about or announce services or service interfaces in ILIAS. This proposal will allow to discover all services that are available in a given ILIAS codebase with a simple grep statement. More complex search or analysis can be build accordingly.
The possibility to define a null object or minimal implementation will provide leafs in the dependency tree that we will need for initialisation. The future initialisation will use a heuristic with some possibility for configuration to define how dependencies are resolved. Minimal or null objects will provide options for that heuristic.
A component may use a service that is defined by another component. When using a service it is crucial that the component only relies on the defined interface and not on some concrete implementation of that service. A good service definition will make this easy and the using component will be able to use all implementations of the service equally. Other than with Pull Code, which is described later on, we are not really interested in any details regarding the service, such as state, configuration etc. We just want any implementation that matches services interface.
This coupling is littler stronger then a mere service definition because the component depends on some component providing the service. It does not need to know any implementor of the service and these can be exchanged, so there is no strong dependency on a certain implementation.
To use a service implemented by some other component, access the $use
container
with the name of the service. The used services will mostly be passed to some
other object as a dependency, for example some internal factory. Keep in mind
that we cannot expect a certain implementation for a given service, only some
implementation for the required interface.
namespace Vendor;
use Vendor\Component\Service;
class Component implements \ILIAS\Core\Component
{
public function init(
// ...
array | \ArrayAccess &$use,
// ...
array | \ArrayAccess &$internal,
) {
$internal["internal_thingy"] = fn () => new InternalThingy($use[Vendor\Component\SomeService::class]);
}
Components might implement services that are defined by other components or themselves. The component provides some concrete implementation that fulfils requirements defined by some interface. To be a good implementation, it should accurately follow the specification of the service definition, i.e. neither do more nor less than required. For example, an implementation...:
- ...should accept all arguments from the outside that fit the service definition and not fail on any of these arguments
- ...should not throw exceptions to open up side channels to the defined interface
- ...should implement all required parts of the interface and not fail on certain calls.
It is absolutely fine, though, if various interfaces and service definitions are implemented by the same class.
The coupling here is even stronger than it is the case when we use a service. In both cases the component depends on the component defining the service. But while we might ignore certain parts of an interface when just using the service, we can not do so when we implement a service. Some users might rely the complete interface.
To declare that a component implements some service (be it defined by itself, be it
defined by some other component) the implementation is inserted to the $implement
container:
namespace Vendor;
use Vendor\Component\Service;
class Component implements \ILIAS\Core\Component
{
public function init(
// ...
array | \ArrayAccess &$implement,
// ...
) {
$implement[OtherVendor\SomeComponent\SomeService::class] = fn () => new MyImplementation(/*...*/);
}
The implementation could $use
, $seek
or $pull
from other components and
also user $internal
facilities.
Some components need contributions from other services to provide their services to
the system. In this type of dependency the component implements a service
,
or functionality, but cannot do all the work on its own. Instead, it needs support
from other components and hence requires contributions from them.
The event system Services\EventHandling
provides the service to raise and handle
events, in definition and implementation. Still, on its own, it won't be able to do
anything useful, as it hasn't handlers that do the actual handling of events. So
Services\EventHandling
seeks contributions of event handlers from other services.
This superficially might look like the definition of a service, as the seeking component will most certainly provide some interface that needs to be implemented by the contributing component. Still, the dependency is reversed: it is the seeker that needs to contributing component to do its work.
This coupling is stronger than the usage of a service, because the seeker needs contributions with an actually useful implementation to do any work. It won't break from a code perspective, if there are no contributions, but it will most certainly feel broken to its user if nothing interesting happens.
To seek for contributions to a service, the $seek
container is used. It may be
queried for a certain interface and returns a list of all contributions that are
made via this interface:
namespace Vendor;
use Vendor\Component\Service;
class Component implements \ILIAS\Core\Component
{
public function init(
// ...
array | \ArrayAccess &$seek,
// ...
array | \ArrayAccess &$internal,
) {
$internal["internal_thingy"] = fn () => new InternalThingy($seek[Vendor\Component\TypeOfContribution::class]);
}
While the fields in other readable containers only return singular objects, the
$seek
container returns lists of objects implementing the seeked interface. These
lists might be empty if no contribution to the interface was made.
Other than for services, possible contributions need not be defined abstractly before
actual contributions are seeked. An abstract definition won't be very useful anyway.
On the one hand, a component might be able to contribute to some service that the
system at hand does not even implement. On the other hand $seek
can always return
an empty array if no contributions exist.
Sometimes components are in a position to contribute to the service or function that another component provides. That other component seeks contributions from other components by defining requirements in form of an interface. The contributing component can implement the requirements and hence participate in the service or functionality.
Many components, for example, contribute entries to the main bar or the meta bar. Both bars would be useless if there were no contributions. But the individual contributors would be just fine without main bar items.
This might superficially look like a combination of a service usage and a service implementation, but the requirements are flipped: The component that contributes to the service does not require the seeking component to function, but the seeking component needs the contributor instead.
To contribute to a service, the $contribute
container is used:
namespace Vendor;
use Vendor\Component\Service;
class Component implements \ILIAS\Core\Component
{
public function init(
// ...
array | \ArrayAccess &$contribute,
// ...
) {
$contribute[Vendor/Component/SomeContribution::class] = fn () => new MyContribution();
}
Components can add various contributions for the same interface. These should just be added one after another:
$contribute[Vendor/Component/SomeContribution::class] = fn () => new FirstContribution();
$contribute[Vendor/Component/SomeContribution::class] = fn () => new SecondContribution();
Some components are, at least partly, providers of code to other components. Although this might look similar to components defining and [implementing](#implement service) services, providing code is another beast:
- Although there might be interfaces guarding internals and implementation details from the outside world (as e.g. the UI Framework does), these interfaces are designed from the perspective of the code that is offered.
- So there is little abstraction going on in these components, instead they might more try to generalize use cases. While abstraction tries to hide complexity behind simpler concepts, generalisation tries to tame complexity by spanning and unifying various concepts.
- Users will become dependent on any behaviour of our code. It won't matter if the behaviour is indeed intended or just emerged accidentially. A bugfix for the one might be a breaking change for the other.
- The code will need to work. Now. In every combination. As intended by the user.
So, if possible, components should try to provide their functionality by a combination of a service definition as thin as possible, together with an according implementation. Still, the system needs functionality that can only be understood as a code repository. Also, many components will want to provide smallish snippets to other components, e.g. abstract base classes, traits or utility classes to be used for other integration patterns.
Examples for components that can be seen as repositories of code are the
Refinery
and the UI-Framework
. The setup mostly acts
as a seeker of contributions
to its functionality, but also provides some snippets of code to its contributors.
It might look as if the components that use the code are more dependent on the provider as the other way round. This is not the case: While the components using the code only depend on the parts they use, the providing component depends on all the usages in the sense that these usages force requirements on the implementation (explicitly and implicitly). This restricts possible implementations in the provider and changes thereof. This also implies, that the highlander principle applies to code providers: there can only be one.
The preferred mechanism to provide code looks just like the according mechanism for services. Other than for services, an implementation must be provided and it may use other dependencies from the system.
namespace Vendor;
use Vendor\Component\Service;
class Component implements \ILIAS\Core\Component
{
public function init(
// ...
array | \ArrayAccess &$provide,
// ...
) {
$provide[Vendor\Component\SomeCode::class] = fn () => new Vendor\Component\SomeCode(/*...*/);
}
Still, there are important differences:
- While some specific service could be implemented by various components, code with a certain name can only be provided by one component.
- While for services we always use interfaces to name and describe them, we might use classes to do so for code.
- The component providing the code also owns the interface or class that is used to name the code. In some circumstances, there will be only one class, providing the name and the code. On the other hand, components can implement services that are defined by another component.
Code provided via this mechanism could for example be a factory or a library of
functions. It can and often should be accompanied by interfaces that define how
the code is used and classes for, e.g., DTOs or other even other purposes. The
definition of the code provided via the $provide
container should allow its users
to discover the connected classes and interfaces. Explanations, howtos, tutorials
and such can be given in the components doc
-folder.
Another way to provide code to other components is to offer trait
s to users. These
can be useful to help users to implement certain, maybe common, parts of interfaces
or encapsulate algorithms for easier use.
In general, we should try to provide code in the most abstract way that is available.
Can you provide a factory instead of making users use new
with a certain name?
Great, do it. Can we use composition (e.g. traits) instead of inheritance (abstract
base class)? Awesome. Just because we are providing code (instead of services) we
should not forget to strive for a loose coupling between components.
Thus, we should not use bare functions
or static methods to provide code to
some other component. We can always replace these to techniques with an injected
class carrying the functions. This will to replace the calls, e.g. for unit tests.
The ways to pull code should come at no surprise given the previous chapter. Developers often have a rather strange relationship to other peoples code. On the one hand we love to pull some dependency from somewhere into our project so we do not need to write it (whatever it is) on our own. This makes us productive. On the other hand, we often tend to rewrite stuff that has been done a hundred times before. This makes us slow. To keep a system like ILIAS coherent and workable we should fall on neiher side:
- Only pull code into your component if you are positive that it is indeed meant to be used by other components in the intended way. Documentation and the $provide-array should already help a lot. When in doubt: ask.
- If there is code designed to solve your problem, use it. When it does not fit exactly or it lacks ease of use, just improve it instead of implementing your own solution. Sharing common code is at the core of every open source community.
This is the preferred mechanism to pull code from another component:
namespace Vendor;
use Vendor\Component\Service;
class Component implements \ILIAS\Core\Component
{
public function init(
// ...
array | \ArrayAccess &$pull,
// ...
) {
$internal["internal_thingy"] = fn () => new InternalThingy($pull[Vendor\Component\TypeOfContribution::class]);
}
There is a variety of other methods that can be used to pull code. Look into the documentation of the component you want to use.
- As of creation of the release_9-branch, CaT will ask for a quick stop in new
commits and move existing code into the new file system structure.
- The actual internal structure for the ILIAS component can be introduced bit by bit. Currently, most components in ILIAS rely on the classmap of composer anyway to locate classes in the codebase. This will simply keep working even if directories are moved inside the codebase. The classmap created by composer will simply point to other locations.
- Most important endpoints and js-includes will be moved with the PR.
- Plugins can continue to use the existing slots for at least ILIAS 10. The implementation status for this proposal should be assessed during the development of ILIAS 10 (about a year from now...) to reconsider when to abandon existing plugin slots.
- After the PR to introduce namespaces is merged, CaT will propose a second PR, that:
- introduces a
$COMPONENT.php
for every component - moves integrations for these components to the new system:
- COPage
- Logging
- Setup
- Badge
- WebAccessChecker
- Object
- EventHandling
- Cron
- SystemCheck
- moves current initialisation into a
ILIAS\Legacy
component:- move
Dependencies
fromServices\Init
to the respective components - initialize DIC in Component::init
- potentially: improve structure of initialisation
- move
- takes care about endpoints, so they will exist in the
public
folder afterwards
- introduces a
- DIC can be used for at least ILIAS 10. The implementation status for this proposal should be assessed during the development of ILIAS 10 (about a year from now...) to reconsider when to abandon DIC.
- CaT will provide migrations and howtos to move existing installations to new structure.